diff --git a/l10n/fluent.js b/l10n/fluent.js index 644a4338c..57eff8a17 100644 --- a/l10n/fluent.js +++ b/l10n/fluent.js @@ -1,14 +1,12 @@ import { FluentBundle, FluentResource } from "@fluent/bundle"; -import insane from "insane"; +import DOMPurify from "isomorphic-dompurify"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import enUS_ftl from "./locales/en-US.ftl"; /** * @import { FluentVariable } from "@fluent/bundle" - * @import { AllowedTags } from "insane" */ - /** @type {Record} */ const ftlMap = { "en-US": enUS_ftl, @@ -75,50 +73,113 @@ export class Fluent { * @returns {string | ReturnType} */ static sanitize(message, elements = {}) { - /** @type { Record } */ - const allowedAttributes = {}; - for (const t of Object.values(elements)) { - allowedAttributes[t.tag] = [ - ...Object.keys(t).filter((x) => x !== "tag"), - ...ALLOWED_ATTRIBUTES, - ]; + /** @type {Record} */ + const allowedAttributesPerTag = Object.fromEntries( + Object.values(elements).map((t) => [ + t.tag, + [...Object.keys(t).filter((x) => x !== "tag"), ...ALLOWED_ATTRIBUTES], + ]), + ); + + for (const tag of ALLOWED_TAGS) { + allowedAttributesPerTag[tag] ??= [...ALLOWED_ATTRIBUTES]; } + // Global list for DOMPurify (we'll filter per-tag in the hook) + const allAllowedAttributes = [ + "data-l10n-name", + ...ALLOWED_ATTRIBUTES, + ...Object.values(elements).flatMap((t) => + Object.keys(t).filter((x) => x !== "tag"), + ), + ]; + const allowedTags = [ ...Object.values(elements).map((t) => t.tag), ...ALLOWED_TAGS, ]; let safe = true; - const sanitized = insane( - message, - { - allowedAttributes, - allowedTags: /** @type {AllowedTags[]} */ (allowedTags), - allowedSchemes: ["http", "https", "mailto"], - filter(token) { - // TODO: use element names directly - const name = token.attrs["data-l10n-name"]; - if (name) { - for (const [k, v] of Object.entries(elements[name] || {})) { - token.attrs[k] = v; + + DOMPurify.addHook("uponSanitizeElement", (node, data) => { + if (node.nodeType !== 1) { + return; + } + const name = + "dataset" in node + ? /** @type {HTMLElement} */ (node).dataset.l10nName + : undefined; + data.allowedTags[data.tagName] = + ALLOWED_TAGS.has(data.tagName) || + !!( + name && + Object.hasOwn(elements, name) && + elements[name]?.tag === data.tagName + ); + }); + + // Configured attributes must pass DOMPurify's validation too. + DOMPurify.addHook( + "beforeSanitizeAttributes", + /** @param {Element} element */ (element) => { + if (!element.tagName) { + return; + } + const tagName = element.tagName.toLowerCase(); + const name = + "dataset" in element + ? /** @type {HTMLElement} */ (element).dataset.l10nName + : undefined; + + const elementConfig = + name && + Object.hasOwn(elements, name) && + elements[name]?.tag === tagName + ? elements[name] + : undefined; + if (elementConfig) { + for (const [k, v] of Object.entries(elementConfig)) { + if (k !== "tag") { + element.setAttribute(k, String(v)); } } + } + + const allowedForTag = allowedAttributesPerTag[tagName] || []; + const attrsToRemove = []; + for (const attr of element.attributes) { if ( - ALLOWED_TAGS.has(token.tag) || - (name && - Object.keys(elements).includes(name) && - elements[name]?.tag === token.tag) + attr.name !== "data-l10n-name" && + !allowedForTag.includes(attr.name) ) { - safe = false; - return true; + attrsToRemove.push(attr.name); } - return false; - }, + } + for (const attr of attrsToRemove) { + element.removeAttribute(attr); + } + + if ( + ALLOWED_TAGS.has(tagName) || + (elementConfig && elementConfig.tag === tagName) + ) { + safe = false; + } }, - true, ); - return safe ? sanitized : unsafeHTML(sanitized); + + try { + const sanitized = DOMPurify.sanitize(message, { + ALLOWED_TAGS: allowedTags, + ALLOWED_ATTR: allAllowedAttributes, + // Allow approved schemes and relative URLs with no scheme before /, ?, or #. + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^:/?#]*(?:[/?#]|$))/i, + }); + return safe ? sanitized : unsafeHTML(sanitized); + } finally { + DOMPurify.removeHook("beforeSanitizeAttributes"); + DOMPurify.removeHook("uponSanitizeElement"); + } } /** diff --git a/package-lock.json b/package-lock.json index e423d943b..743774d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "fdir": "^6.5.0", "he": "^1.2.0", "http-proxy-middleware": "^4.2.0", - "insane": "^2.6.2", + "isomorphic-dompurify": "^4.2.0", "lit": "^3.3.3", "lit-html": "^3.3.3", "open-editor": "^6.0.0", @@ -65,7 +65,6 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", "@types/he": "^1.2.3", - "@types/insane": "^1.0.0", "@types/mocha": "^10.0.10", "@types/node": "^24.13.5", "@types/prismjs": "^1.26.6", @@ -151,6 +150,55 @@ "url": "https://github.com/sponsors/philsturgeon" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1954,6 +2002,18 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.5.2.tgz", @@ -2360,7 +2420,6 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", - "dev": true, "funding": [ { "type": "github", @@ -2380,7 +2439,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, "funding": [ { "type": "github", @@ -2404,7 +2462,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", - "dev": true, "funding": [ { "type": "github", @@ -2432,7 +2489,6 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", - "dev": true, "funding": [ { "type": "github", @@ -2456,7 +2512,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, "funding": [ { "type": "github", @@ -2479,7 +2534,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", - "dev": true, "funding": [ { "type": "github", @@ -2504,7 +2558,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, "funding": [ { "type": "github", @@ -4682,6 +4735,23 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fast-csv/parse": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-5.0.5.tgz", @@ -9458,13 +9528,6 @@ "@types/node": "*" } }, - "node_modules/@types/insane": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/insane/-/insane-1.0.0.tgz", - "integrity": "sha512-9FNbmwdaQezEszc5B/w4kRSpMJMOVj+gX7CKSbBCFO4WPiUqKO3HJlUNXzjtus0w5tF2BOJoKTbyps/Envlg/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -11597,11 +11660,6 @@ "node": ">= 0.4" } }, - "node_modules/assignment": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/assignment/-/assignment-2.0.0.tgz", - "integrity": "sha512-naMULXjtgCs9SVUEtyvJNt68aF18em7/W+dhbR59kbz9cXWPEvUkCun2tqlgqRPSqZaKPpqLc5ZnwL8jVmJRvw==" - }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -11907,6 +11965,15 @@ "node": ">=10.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -13805,7 +13872,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -13996,6 +14062,33 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -14026,6 +14119,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", @@ -14312,6 +14411,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -17247,6 +17355,18 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -17984,25 +18104,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/insane": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/insane/-/insane-2.6.2.tgz", - "integrity": "sha512-BqEL1CJsjJi+/C/zKZxv31zs3r6zkLH5Nz1WMFb7UBX2KHY2yXDpbFTSEmNHzomBbGDysIfkTX55A0mQZ2CQiw==", - "license": "MIT", - "dependencies": { - "assignment": "2.0.0", - "he": "0.5.0" - } - }, - "node_modules/insane/node_modules/he": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/he/-/he-0.5.0.tgz", - "integrity": "sha512-DoufbNNOFzwRPy8uecq+j+VCPQ+JyDelHTmSgygrA5TsR8Cbw4Qcir5sGtWiusB4BdT89nmlaVDhSJOqC/33vw==", - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, "node_modules/ip-address": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", @@ -18254,7 +18355,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, "license": "MIT" }, "node_modules/is-promise": { @@ -18332,6 +18432,19 @@ "node": ">=0.10.0" } }, + "node_modules/isomorphic-dompurify": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-4.2.0.tgz", + "integrity": "sha512-0rp/dna3nh3lQNZrWRJTZzRS8rSYBFXq7zuDOMn0MiklhFyND8fQqQbAcVFv1MkRJ2xDhgR6G7E0bl1msYQKTQ==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.4.12", + "jsdom": "^30.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -18737,6 +18850,88 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -19977,7 +20172,6 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -23776,7 +23970,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -24700,7 +24893,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -25510,6 +25702,18 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -26045,7 +26249,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -26886,6 +27089,12 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/sync-child-process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", @@ -27250,6 +27459,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -27317,6 +27544,30 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-dump": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", @@ -28117,6 +28368,18 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-port": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", @@ -28332,6 +28595,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack": { "version": "5.105.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", @@ -28519,6 +28791,29 @@ "node": ">=4.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -28697,6 +28992,15 @@ "xml-js": "bin/cli.js" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -28721,6 +29025,12 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index faa78ce15..88d81a519 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "fdir": "^6.5.0", "he": "^1.2.0", "http-proxy-middleware": "^4.2.0", - "insane": "^2.6.2", + "isomorphic-dompurify": "^4.2.0", "lit": "^3.3.3", "lit-html": "^3.3.3", "open-editor": "^6.0.0", @@ -87,7 +87,6 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", "@types/he": "^1.2.3", - "@types/insane": "^1.0.0", "@types/mocha": "^10.0.10", "@types/node": "^24.13.5", "@types/prismjs": "^1.26.6", diff --git a/rspack.config.js b/rspack.config.js index 969522815..2dc28bcc0 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -255,6 +255,10 @@ const clientAndSsrCommon = { const ssrConfig = merge(common, notServiceWorkerCommon, clientAndSsrCommon, { name: "ssr", target: "node22", + externals: { + // jsdom reads package-relative assets that cannot be bundled. + "isomorphic-dompurify": "module isomorphic-dompurify", + }, async entry() { return { index: [ diff --git a/test/unit/l10n/fluent.test.js b/test/unit/l10n/fluent.test.js new file mode 100644 index 000000000..9e3f6f6cd --- /dev/null +++ b/test/unit/l10n/fluent.test.js @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; + +import { readFileSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { afterEach, describe, it } from "node:test"; + +import DOMPurify from "isomorphic-dompurify"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + +const hooks = registerHooks({ + load(url, context, nextLoad) { + if (url.endsWith(".ftl")) { + return { + format: "module", + source: `export default ${JSON.stringify(readFileSync(new URL(url), "utf8"))};`, + shortCircuit: true, + }; + } + return nextLoad(url, context); + }, +}); +const { Fluent } = await import("../../../l10n/fluent.js"); +hooks.deregister(); + +describe("Fluent.sanitize", () => { + afterEach(() => DOMPurify.removeAllHooks()); + + for (const { name, input, expected } of [ + { name: "plain text", input: "Hello", expected: "Hello" }, + { + name: "allowed formatting", + input: "Hello", + expected: unsafeHTML("Hello"), + }, + ]) { + it(`preserves ${name}`, () => { + assert.deepEqual(Fluent.sanitize(input), expected); + }); + } + + it("does not reuse attributes from earlier translations", () => { + Fluent.sanitize('First', { + link: { tag: "a", href: "https://example.com/first" }, + }); + assert.deepEqual( + Fluent.sanitize('Second', { + link: { tag: "a", title: "Second" }, + }), + unsafeHTML('Second'), + ); + }); + + it("removes its hook when sanitization throws", () => { + const input = 'Link'; + assert.throws(() => + Fluent.sanitize(input, { link: { tag: "a", "invalid attribute": "x" } }), + ); + assert.deepEqual( + Fluent.sanitize(input, { link: { tag: "a" } }), + unsafeHTML(input), + ); + }); + + for (const { name, input, expected } of [ + { + name: "matching names and tags", + input: 'Link', + expected: 'Link', + }, + { + name: "unnamed tags", + input: 'Link', + expected: "Link", + }, + { + name: "unknown names", + input: 'Link', + expected: "Link", + }, + { + name: "mismatched tags", + input: 'Link', + expected: "Link", + }, + { + name: "unnamed tags after matching tags", + input: 'LinkExtra', + expected: + 'LinkExtra', + }, + { + name: "matching tags after unnamed tags", + input: 'ExtraLink', + expected: + 'ExtraLink', + }, + ]) { + it(`filters configured elements with ${name}`, () => { + assert.deepEqual( + Fluent.sanitize(`Text${input}`, { + link: { tag: "a", href: "https://example.com" }, + code: { tag: "code" }, + }), + unsafeHTML(`Text${expected}`), + ); + }); + } + + for (const { name, href, allowed } of [ + { name: "HTTPS", href: "https://example.com", allowed: true }, + // HTTP remains an explicitly supported link scheme. + // eslint-disable-next-line unicorn/prefer-https + { name: "HTTP", href: "http://example.com", allowed: true }, + { name: "email", href: "mailto:hello@example.com", allowed: true }, + { name: "root relative", href: "/en-US/docs/Web", allowed: true }, + { name: "path relative", href: "../Web", allowed: true }, + { name: "bare relative", href: "Web", allowed: true }, + { name: "fragment", href: "#section", allowed: true }, + { name: "query", href: "?q=javascript:example", allowed: true }, + { name: "colon in path", href: "/docs/Example:Page", allowed: true }, + { name: "protocol relative", href: "//example.com", allowed: true }, + { name: "JavaScript", href: "javascript:alert(1)", allowed: false }, + { + name: "mixed-case JavaScript", + href: "JaVaScRiPt:alert(1)", + allowed: false, + }, + { + name: "obfuscated JavaScript", + href: "java\nscript:alert(1)", + allowed: false, + }, + { name: "data", href: "data:text/html,example", allowed: false }, + { name: "unapproved scheme", href: "ftp://example.com", allowed: false }, + ]) { + it(`validates configured ${name} URLs`, () => { + assert.deepEqual( + Fluent.sanitize('Link', { + link: { tag: "a", href }, + }), + unsafeHTML( + `Link`, + ), + ); + }); + } +});