From 65831785a2f1a52b0d3045b4a0b34fbfb37e94ca Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 6 Sep 2026 22:57:47 +0200 Subject: [PATCH] fix(security): bump sanitize-html to 2.17.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trivy flags the backend image on two sanitize-html advisories, both fixed upstream: - CVE-2026-63670 (fixed 2.17.6): a literal solidus after a raw-text end tag (``) is treated as text by htmlparser2 and re-emitted unescaped, so disallowed markup passes when textarea or xmp is in allowedTags. - CVE-2026-84371 (fixed 2.17.7): an SVG SMIL animation whose attributeName selects href lets the sibling values/from/to/by attributes carry URLs past the scheme policy. 2.17.5 -> 2.17.7, exact pin as before. The new version brings its own htmlparser2 12 / domhandler 6 / domutils 4 / dom-serializer 3 / entities 8 tree under node_modules/sanitize-html; nothing else in the lock moves. That tree is ESM-only, so the backend now needs unflagged require(esm): Node 20.19+ or 22.12+. The image is node:22-alpine and CI runs 22, but engines.node still admitted 22.0-22.11, where require('sanitize-html') throws ERR_REQUIRE_ESM at startup (publicSiteService loads it during initialisation). engines is now ^20.19.0 || >=22.12.0 and the native setup script's Node check enforces the same range instead of accepting any 22.x. On the supported versions the sanitiser behaves identically to 2.17.5 on the tracker and newsletter fixtures. Jest 29's CommonJS registry cannot evaluate ESM either, so every suite importing a route or service that uses the sanitiser would fail at import. jest.config.js now maps `sanitize-html` to jest.sanitizeHtml.js, which hands that one module to Node's real loader via process.getBuiltinModule('module') — a plain require('module') inside Jest is Jest's wrapper and returns an empty object for this package. Verified against a real 2.17.7 install: the sanitiser suites and a settings route suite pass; without the mapper they fail with "Cannot use import statement outside a module". --- backend/jest.config.js | 6 +- backend/jest.sanitizeHtml.js | 18 ++++++ backend/package-lock.json | 114 +++++++++++++++++++++++++++++++++-- backend/package.json | 4 +- scripts/picpeak-setup.sh | 25 ++++++-- 5 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 backend/jest.sanitizeHtml.js diff --git a/backend/jest.config.js b/backend/jest.config.js index ff65705e..9e0853f7 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -13,5 +13,9 @@ module.exports = { testMatch: [ '**/__tests__/**/*.test.js' ], - setupFilesAfterEnv: ['/jest.setup.js'] + setupFilesAfterEnv: ['/jest.setup.js'], + // sanitize-html's htmlparser2 12 is ESM-only; see jest.sanitizeHtml.js. + moduleNameMapper: { + '^sanitize-html$': '/jest.sanitizeHtml.js' + } }; diff --git a/backend/jest.sanitizeHtml.js b/backend/jest.sanitizeHtml.js new file mode 100644 index 00000000..65766b3a --- /dev/null +++ b/backend/jest.sanitizeHtml.js @@ -0,0 +1,18 @@ +/** + * sanitize-html 2.17.6+ depends on htmlparser2 12, which ships ESM only. + * Node 22.12+ loads it fine through require(esm); Jest 29's CommonJS module + * registry cannot evaluate an ESM file and fails every suite that imports a + * route or service using the sanitiser. Rather than bolting a Babel + * transform onto node_modules for one dependency, hand this single module to + * Node's own loader. + * + * process.getBuiltinModule (Node 22.3+) is the real core `module` even inside + * Jest — a plain require('module') here returns Jest's wrapper, whose + * createRequire() hands back an empty object for this package. createRequire() + * on the real one resolves from backend/node_modules exactly like production. + * + * Wired in via moduleNameMapper in jest.config.js. The module is stateless, + * so sharing one instance across test files changes nothing; it just cannot + * be jest.mock()ed, and nothing mocks it. + */ +module.exports = process.getBuiltinModule('module').createRequire(__filename)('sanitize-html'); diff --git a/backend/package-lock.json b/backend/package-lock.json index 2d8faaf3..9e25f833 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -52,7 +52,7 @@ "postcss": "8.5.23", "qrcode": "^1.5.4", "react-i18next": "^15.6.0", - "sanitize-html": "2.17.5", + "sanitize-html": "2.17.7", "sharp": "0.35.3", "sqlite3": "^5.1.6", "swagger-jsdoc": "^6.2.8", @@ -11050,18 +11050,122 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.5", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz", - "integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==", + "version": "2.17.7", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", + "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", - "htmlparser2": "^10.1.0", + "htmlparser2": "^12.0.0", "is-plain-object": "^5.0.0", "launder": "^1.7.1", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/sanitize-html/node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/sanitize-html/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" } }, "node_modules/selderee": { diff --git a/backend/package.json b/backend/package.json index 80f46dbe..93cd11ba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "engines": { - "node": "^20.19.0 || >=22" + "node": "^20.19.0 || >=22.12.0" }, "scripts": { "start": "node server.js", @@ -62,7 +62,7 @@ "postcss": "8.5.23", "qrcode": "^1.5.4", "react-i18next": "^15.6.0", - "sanitize-html": "2.17.5", + "sanitize-html": "2.17.7", "sharp": "0.35.3", "sqlite3": "^5.1.6", "swagger-jsdoc": "^6.2.8", diff --git a/scripts/picpeak-setup.sh b/scripts/picpeak-setup.sh index b7e64e5b..2ef20be3 100755 --- a/scripts/picpeak-setup.sh +++ b/scripts/picpeak-setup.sh @@ -15,7 +15,7 @@ readonly SCRIPT_VERSION="2.1.0" readonly APP_NAME="PicPeak" readonly REPO_URL="https://github.com/PicPeak/picpeak.git" readonly NODE_VERSION="20" -readonly NODE_MIN_VERSION="20.19.0" # backend engines: ^20.19.0 || >=22 (sharp 0.35, html-to-text 10) +readonly NODE_MIN_VERSION="20.19.0" # backend engines: ^20.19.0 || >=22.12.0 (sharp 0.35, html-to-text 10; sanitize-html 2.17.7 needs require(esm), unflagged in 20.19 and 22.12) readonly MIN_RAM_DOCKER=2048 readonly MIN_RAM_NATIVE=1024 readonly MIN_DISK_GB=2 @@ -822,6 +822,19 @@ EOF # Native Installation ################################################################################ +# True when a Node.js version satisfies the backend's engines range +# (^20.19.0 || >=22.12.0). 21.x is out, and so is 22.0-22.11. +node_version_supported() { + local ver="$1" major minor + major="${ver%%.*}" + minor="${ver#*.}"; minor="${minor%%.*}" + [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]] || return 1 + [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$ver" | sort -V | head -1)" == "$NODE_MIN_VERSION" ]] || return 1 + [[ "$major" == "21" ]] && return 1 + [[ "$major" == "22" && "$minor" -lt 12 ]] && return 1 + return 0 +} + install_nodejs() { # --update dispatches here before main() runs detect_os, so detect on demand if [[ -z "$PACKAGE_MANAGER" ]]; then @@ -830,8 +843,10 @@ install_nodejs() { local node_ver node_ver=$(command_exists node && node -v | cut -d'v' -f2 || echo "0") - # backend engines range is ^20.19.0 || >=22 (Node 21 is excluded by the glob/minimatch family) - if [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$node_ver" | sort -V | head -1)" == "$NODE_MIN_VERSION" && "${node_ver%%.*}" != "21" ]]; then + # backend engines range is ^20.19.0 || >=22.12.0 (Node 21 is excluded by the glob/minimatch + # family; 22.0-22.11 lack unflagged require(esm), which sanitize-html 2.17.7's ESM-only + # htmlparser2 needs — the backend would not start) + if node_version_supported "$node_ver"; then log_success "Node.js $(node -v) is already installed" return fi @@ -851,8 +866,8 @@ install_nodejs() { # Package managers won't downgrade a newer Node (e.g. 21), so re-verify before continuing node_ver=$(command_exists node && node -v | cut -d'v' -f2 || echo "0") - if [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$node_ver" | sort -V | head -1)" != "$NODE_MIN_VERSION" || "${node_ver%%.*}" == "21" ]]; then - die "Node.js v$node_ver does not satisfy the backend requirement (^$NODE_MIN_VERSION || >=22); remove the current Node.js, install a supported version, then re-run this script" + if ! node_version_supported "$node_ver"; then + die "Node.js v$node_ver does not satisfy the backend requirement (^$NODE_MIN_VERSION || >=22.12.0); remove the current Node.js, install a supported version, then re-run this script" fi log_success "Node.js installed: $(node -v)" }