From 501737b4315344250ef771571cb0f689265a27e4 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 21 Jun 2026 13:17:32 +0100 Subject: [PATCH 1/4] chore(deps): vendor the unmaintained `security` escaper into core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `security` npm package (escapeHTML / escapeHTMLAttribute and the JS/CSS encoders) has had no release since 2012, yet it sits directly in Etherpad's client-side XSS-defense path (pad_utils, domline) and the server-side HTML export. Rather than keep a 14-year-old, single-maintainer dependency guarding output encoding, vendor its implementation into core. - static/js/security.ts now contains the escaping logic directly (reproduced verbatim from security@1.0.0, MIT, Chad Weider — byte-identical output) and no longer does `require('security')`. The full public API is preserved, so plugins that `require('ep_etherpad-lite/static/js/security')` keep working unchanged. - pad_utils.ts requires the local './security' module instead of the bare 'security' specifier (domline.ts and ExportHtml.ts already did). - Drop `security` from src/package.json dependencies and from Minify's LIBRARY_WHITELIST (no bare specifier is served to the browser anymore). Added tests/backend/specs/security.ts locking the byte-for-byte escaping output so the vendored copy can never silently drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 18 ++----- src/node/utils/Minify.ts | 1 - src/package.json | 1 - src/static/js/pad_utils.ts | 2 +- src/static/js/security.ts | 82 ++++++++++++++++++++++++----- src/tests/backend/specs/security.ts | 64 ++++++++++++++++++++++ 6 files changed, 140 insertions(+), 28 deletions(-) create mode 100644 src/tests/backend/specs/security.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd0ff755ef6..70ce7db4257 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -340,9 +340,6 @@ importers: rusty-store-kv: specifier: ^1.3.1 version: 1.3.1 - security: - specifier: 1.0.0 - version: 1.0.0 semver: specifier: ^7.8.4 version: 7.8.4 @@ -4991,9 +4988,6 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - security@1.0.0: - resolution: {integrity: sha512-5qfoAgfRWS1sUn+fUJtdbbqM1BD/LoQGa+smPTDjf9OqHyuJqi6ewtbYL0+V1S1RaU6OCOCMWGZocIfz2YK4uw==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -8419,7 +8413,7 @@ snapshots: '@rushstack/eslint-patch': 1.16.1 '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3) '@typescript-eslint/parser': 7.18.0(eslint@10.5.0)(typescript@6.0.3) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0))(eslint@10.5.0) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0)(eslint@10.5.0) eslint-plugin-cypress: 2.15.2(eslint@10.5.0) eslint-plugin-eslint-comments: 3.2.0(eslint@10.5.0) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.5.0) @@ -8443,7 +8437,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0))(eslint@10.5.0): + eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0)(eslint@10.5.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@8.1.1) @@ -8458,14 +8452,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0))(eslint@10.5.0))(eslint@10.5.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1)(eslint@10.5.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@10.5.0)(typescript@6.0.3) eslint: 10.5.0 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0))(eslint@10.5.0) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0)(eslint@10.5.0) transitivePeerDependencies: - supports-color @@ -8498,7 +8492,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.5.0 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0))(eslint@10.5.0))(eslint@10.5.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.5.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1)(eslint@10.5.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -10605,8 +10599,6 @@ snapshots: secure-json-parse@4.1.0: {} - security@1.0.0: {} - semver@6.3.1: {} semver@7.8.4: {} diff --git a/src/node/utils/Minify.ts b/src/node/utils/Minify.ts index 8747ff04b14..5334de8a921 100644 --- a/src/node/utils/Minify.ts +++ b/src/node/utils/Minify.ts @@ -39,7 +39,6 @@ const ROOT_DIR = path.join(settings.root, 'src/static/'); const LIBRARY_WHITELIST = [ 'async', 'js-cookie', - 'security', 'split-grid', 'tinycon', 'underscore', diff --git a/src/package.json b/src/package.json index 3939840845b..c1991349bb2 100644 --- a/src/package.json +++ b/src/package.json @@ -79,7 +79,6 @@ "resolve": "1.22.12", "rethinkdb": "^2.4.2", "rusty-store-kv": "^1.3.1", - "security": "1.0.0", "semver": "^7.8.4", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", diff --git a/src/static/js/pad_utils.ts b/src/static/js/pad_utils.ts index 997dd2be6ee..9983ad5d3c8 100644 --- a/src/static/js/pad_utils.ts +++ b/src/static/js/pad_utils.ts @@ -24,7 +24,7 @@ import {binarySearch} from "./ace2_common"; * limitations under the License. */ -const Security = require('security'); +const Security = require('./security'); import jsCookie, {CookiesStatic} from 'js-cookie' /** diff --git a/src/static/js/security.ts b/src/static/js/security.ts index d5f9b726622..0969f62b87d 100644 --- a/src/static/js/security.ts +++ b/src/static/js/security.ts @@ -1,20 +1,78 @@ -// @ts-nocheck 'use strict'; /** - * Copyright 2009 Google Inc. + * OWASP-style output-escaping helpers. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Vendored from the `security` npm package (v1.0.0), which has been + * unmaintained since 2012. The implementation below is reproduced verbatim + * (behaviour is byte-identical) so the dependency can be dropped from core. * - * http://www.apache.org/licenses/LICENSE-2.0 + * Original work Copyright (c) 2011 Chad Weider, MIT licensed: * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS-IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. */ -module.exports = require('security'); +const HTML_ENTITY_MAP: {[c: string]: string} = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', +}; + +// OWASP Guidelines: &, <, >, ", ' plus forward slash. +const HTML_CHARACTERS_EXPRESSION = /[&"'<>/]/gm; +const escapeHTML = (text: string) => text && text.replace(HTML_CHARACTERS_EXPRESSION, + (c: string) => HTML_ENTITY_MAP[c] || c); + +// OWASP Guidelines: escape all non alphanumeric characters in ASCII space. +const HTML_ATTRIBUTE_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm; +const escapeHTMLAttribute = (text: string) => text && text.replace(HTML_ATTRIBUTE_CHARACTERS_EXPRESSION, + (c: string) => HTML_ENTITY_MAP[c] || `&#x${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)};`); + +// OWASP Guidelines: escape all non alphanumeric characters in ASCII space. +// Also include line breaks (for literal). +const JAVASCRIPT_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF\u2028\u2029]/gm; +const encodeJavaScriptIdentifier = (text: string) => text && text.replace(JAVASCRIPT_CHARACTERS_EXPRESSION, + (c: string) => `\\u${(`0000${c.charCodeAt(0).toString(16)}`).slice(-4)}`); + +const encodeJavaScriptString = (text: string) => text && `"${encodeJavaScriptIdentifier(text)}"`; + +// This is not great, but it is useful. +const JSON_STRING_LITERAL_EXPRESSION = /"(?:\\.|[^"])*"/gm; +const encodeJavaScriptData = (object: any) => JSON.stringify(object).replace(JSON_STRING_LITERAL_EXPRESSION, + (string: string) => encodeJavaScriptString(JSON.parse(string))); + +// OWASP Guidelines: escape all non alphanumeric characters in ASCII space. +const CSS_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm; +const encodeCSSIdentifier = (text: string) => text && text.replace(CSS_CHARACTERS_EXPRESSION, + (c: string) => `\\${(`000000${c.charCodeAt(0).toString(16)}`).slice(-6)}`); + +const encodeCSSString = (text: string) => text && `"${encodeCSSIdentifier(text)}"`; + +module.exports = { + escapeHTML, + escapeHTMLAttribute, + encodeJavaScriptIdentifier, + encodeJavaScriptString, + encodeJavaScriptData, + encodeCSSIdentifier, + encodeCSSString, +}; diff --git a/src/tests/backend/specs/security.ts b/src/tests/backend/specs/security.ts new file mode 100644 index 00000000000..8b74413e7d8 --- /dev/null +++ b/src/tests/backend/specs/security.ts @@ -0,0 +1,64 @@ +'use strict'; + +const assert = require('assert').strict; +// The escaping helpers are a client module, but they are pure (no browser +// globals) so they can be exercised directly from a backend spec. This locks +// the byte-for-byte output of the helpers vendored from the (now removed) +// `security` npm package, so the vendoring can never silently drift. +const Security = require('../../../static/js/security'); + +describe(__filename, function () { + describe('public API', function () { + it('exposes the full set of helpers plugins may rely on', function () { + for (const fn of [ + 'escapeHTML', 'escapeHTMLAttribute', + 'encodeJavaScriptIdentifier', 'encodeJavaScriptString', 'encodeJavaScriptData', + 'encodeCSSIdentifier', 'encodeCSSString', + ]) { + assert.equal(typeof Security[fn], 'function', `Security.${fn} must be a function`); + } + }); + }); + + describe('escapeHTML', function () { + it('escapes &, <, >, ", \' and / per OWASP', function () { + assert.equal(Security.escapeHTML('/&\''), + '<a href="x">/&''); + }); + it('neutralises a script tag', function () { + assert.equal(Security.escapeHTML(''), + '<script>alert(1)</script>'); + }); + it('leaves plain alphanumerics untouched', function () { + assert.equal(Security.escapeHTML('Hello World 123'), 'Hello World 123'); + }); + it('passes falsy input straight through', function () { + assert.equal(Security.escapeHTML(''), ''); + }); + }); + + describe('escapeHTMLAttribute', function () { + it('hex-encodes non-alphanumeric ASCII not covered by named entities', function () { + assert.equal(Security.escapeHTMLAttribute('a b'), 'a b'); + // hex is lowercased, matching the original `security` package output. + assert.equal(Security.escapeHTMLAttribute('javascript:alert(1)'), + 'javascript:alert(1)'); + }); + it('prefers named entities for &, <, >, ", \', /', function () { + assert.equal(Security.escapeHTMLAttribute('<>&"\'/'), + '<>&"'/'); + }); + it('leaves alphanumerics untouched', function () { + assert.equal(Security.escapeHTMLAttribute('abcXYZ0189'), 'abcXYZ0189'); + }); + }); + + describe('javascript / css encoders', function () { + it('encodeJavaScriptString quotes and backslash-u-escapes specials', function () { + assert.equal(Security.encodeJavaScriptString('a Date: Sun, 21 Jun 2026 13:50:44 +0100 Subject: [PATCH 2/4] fix: use ESM named exports so vitest can resolve the security module CI "Run the new vitest tests" failed with `Cannot find module './security'` from pad_utils.ts. vitest/vite's CJS require() shim doesn't add a `.ts` extension when resolving a relative specifier, so `require('./security')` couldn't locate security.ts. (The old bare `require('security')` resolved to a real .js in node_modules, which is why this only surfaced after vendoring.) - security.ts now uses ESM `export const` for the seven helpers instead of a `module.exports = {...}` block. - pad_utils.ts imports it as `import * as Security from './security'`, which goes through vite's resolver (knows .ts) and is also properly typed. CJS consumers (domline.ts, ExportHtml.ts, the backend spec) keep working via tsx/esbuild ESM->CJS interop. Verified: tsc clean, full vitest suite 721 passing, and the mocha security/export/import specs 27 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/static/js/pad_utils.ts | 2 +- src/static/js/security.ts | 24 +++++++----------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/static/js/pad_utils.ts b/src/static/js/pad_utils.ts index 9983ad5d3c8..b6f17e84c9d 100644 --- a/src/static/js/pad_utils.ts +++ b/src/static/js/pad_utils.ts @@ -24,7 +24,7 @@ import {binarySearch} from "./ace2_common"; * limitations under the License. */ -const Security = require('./security'); +import * as Security from './security'; import jsCookie, {CookiesStatic} from 'js-cookie' /** diff --git a/src/static/js/security.ts b/src/static/js/security.ts index 0969f62b87d..b3c51642ec2 100644 --- a/src/static/js/security.ts +++ b/src/static/js/security.ts @@ -39,40 +39,30 @@ const HTML_ENTITY_MAP: {[c: string]: string} = { // OWASP Guidelines: &, <, >, ", ' plus forward slash. const HTML_CHARACTERS_EXPRESSION = /[&"'<>/]/gm; -const escapeHTML = (text: string) => text && text.replace(HTML_CHARACTERS_EXPRESSION, +export const escapeHTML = (text: string) => text && text.replace(HTML_CHARACTERS_EXPRESSION, (c: string) => HTML_ENTITY_MAP[c] || c); // OWASP Guidelines: escape all non alphanumeric characters in ASCII space. const HTML_ATTRIBUTE_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm; -const escapeHTMLAttribute = (text: string) => text && text.replace(HTML_ATTRIBUTE_CHARACTERS_EXPRESSION, +export const escapeHTMLAttribute = (text: string) => text && text.replace(HTML_ATTRIBUTE_CHARACTERS_EXPRESSION, (c: string) => HTML_ENTITY_MAP[c] || `&#x${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)};`); // OWASP Guidelines: escape all non alphanumeric characters in ASCII space. // Also include line breaks (for literal). const JAVASCRIPT_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF\u2028\u2029]/gm; -const encodeJavaScriptIdentifier = (text: string) => text && text.replace(JAVASCRIPT_CHARACTERS_EXPRESSION, +export const encodeJavaScriptIdentifier = (text: string) => text && text.replace(JAVASCRIPT_CHARACTERS_EXPRESSION, (c: string) => `\\u${(`0000${c.charCodeAt(0).toString(16)}`).slice(-4)}`); -const encodeJavaScriptString = (text: string) => text && `"${encodeJavaScriptIdentifier(text)}"`; +export const encodeJavaScriptString = (text: string) => text && `"${encodeJavaScriptIdentifier(text)}"`; // This is not great, but it is useful. const JSON_STRING_LITERAL_EXPRESSION = /"(?:\\.|[^"])*"/gm; -const encodeJavaScriptData = (object: any) => JSON.stringify(object).replace(JSON_STRING_LITERAL_EXPRESSION, +export const encodeJavaScriptData = (object: any) => JSON.stringify(object).replace(JSON_STRING_LITERAL_EXPRESSION, (string: string) => encodeJavaScriptString(JSON.parse(string))); // OWASP Guidelines: escape all non alphanumeric characters in ASCII space. const CSS_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm; -const encodeCSSIdentifier = (text: string) => text && text.replace(CSS_CHARACTERS_EXPRESSION, +export const encodeCSSIdentifier = (text: string) => text && text.replace(CSS_CHARACTERS_EXPRESSION, (c: string) => `\\${(`000000${c.charCodeAt(0).toString(16)}`).slice(-6)}`); -const encodeCSSString = (text: string) => text && `"${encodeCSSIdentifier(text)}"`; - -module.exports = { - escapeHTML, - escapeHTMLAttribute, - encodeJavaScriptIdentifier, - encodeJavaScriptString, - encodeJavaScriptData, - encodeCSSIdentifier, - encodeCSSString, -}; +export const encodeCSSString = (text: string) => text && `"${encodeCSSIdentifier(text)}"`; From a3c7198e8af7bde79c5ce60721c412c58d9a9b0e Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 21 Jun 2026 14:24:16 +0100 Subject: [PATCH 3/4] ci: force fresh run (prior run used a stale merge ref after reopen) Co-Authored-By: Claude Opus 4.8 (1M context) From 4a9f02cedc556ab8794221fc98da009892483b80 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 21 Jun 2026 14:32:10 +0100 Subject: [PATCH 4/4] fix: remove ReDoS in vendored JSON-string-literal regex CodeQL flagged a high-severity exponential-backtracking alert on the JSON-string-literal regex vendored from the `security` package: `/"(?:\\.|[^"])*"/`. The `[^"]` class also matches a backslash, so it overlaps with the `\\.` alternative and backtracks exponentially on adversarial input like `"\!\!\!...` (no closing quote). The original lived inside node_modules so it was never scanned; vendoring it surfaced the alert. Fix to the canonical linear form `/"(?:[^"\\]|\\.)*"/`, where the backslash is excluded from the character class so the two alternatives are mutually exclusive. It matches exactly the same well-formed JSON string literals (and encodeJavaScriptData only ever runs it over JSON.stringify output), so behaviour is unchanged for valid input. Added tests: encodeJavaScriptData output + a ReDoS guard that runs the regex over 50k adversarial chars and asserts it returns in well under a second. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/static/js/security.ts | 7 ++++++- src/tests/backend/specs/security.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/static/js/security.ts b/src/static/js/security.ts index b3c51642ec2..cae83bfdb54 100644 --- a/src/static/js/security.ts +++ b/src/static/js/security.ts @@ -56,7 +56,12 @@ export const encodeJavaScriptIdentifier = (text: string) => text && text.replace export const encodeJavaScriptString = (text: string) => text && `"${encodeJavaScriptIdentifier(text)}"`; // This is not great, but it is useful. -const JSON_STRING_LITERAL_EXPRESSION = /"(?:\\.|[^"])*"/gm; +// NB: the original `security` package used /"(?:\\.|[^"])*"/, where `[^"]` also +// matches a backslash and so overlaps with `\\.`, causing exponential +// backtracking (ReDoS) on adversarial input. We exclude the backslash from the +// character class so the two alternatives are mutually exclusive — this matches +// exactly the same well-formed JSON string literals but in linear time. +const JSON_STRING_LITERAL_EXPRESSION = /"(?:[^"\\]|\\.)*"/gm; export const encodeJavaScriptData = (object: any) => JSON.stringify(object).replace(JSON_STRING_LITERAL_EXPRESSION, (string: string) => encodeJavaScriptString(JSON.parse(string))); diff --git a/src/tests/backend/specs/security.ts b/src/tests/backend/specs/security.ts index 8b74413e7d8..47663e223cc 100644 --- a/src/tests/backend/specs/security.ts +++ b/src/tests/backend/specs/security.ts @@ -60,5 +60,19 @@ describe(__filename, function () { it('encodeCSSString quotes and backslash-escapes specials', function () { assert.equal(Security.encodeCSSString('a;b'), '"a\\00003bb"'); }); + it('encodeJavaScriptData escapes specials inside JSON string literals', function () { + assert.equal( + Security.encodeJavaScriptData({a: '', c: 'x"y', d: 'a\\b'}), + '{"a":"\\u003cb\\u003e","c":"x\\u0022y","d":"a\\u005cb"}'); + }); + it('encodeJavaScriptData regex is linear (ReDoS guard)', function () { + // The JSON-string-literal regex used to be /"(?:\\.|[^"])*"/, which + // backtracks exponentially on an unterminated string of `\!` repeats. + // Run the regex directly on adversarial input and assert it returns fast. + const evil = `"${'\\!'.repeat(50000)}`; // no closing quote + const start = Date.now(); + /"(?:[^"\\]|\\.)*"/gm.test(evil); + assert.ok(Date.now() - start < 1000, 'regex must not backtrack exponentially'); + }); }); });