mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
chore(deps): vendor the unmaintained security escaper into core (#7993)
* chore(deps): vendor the unmaintained `security` escaper into core
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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* ci: force fresh run (prior run used a stale merge ref after reopen)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7168f14f0a
commit
01500ca70c
6 changed files with 149 additions and 28 deletions
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
|
|
@ -337,9 +337,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
|
||||
|
|
@ -4972,9 +4969,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
|
||||
|
|
@ -8394,7 +8388,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)
|
||||
|
|
@ -8418,7 +8412,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)
|
||||
|
|
@ -8433,14 +8427,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
|
||||
|
||||
|
|
@ -8473,7 +8467,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
|
||||
|
|
@ -10576,8 +10570,6 @@ snapshots:
|
|||
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
security@1.0.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.4: {}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ const ROOT_DIR = path.join(settings.root, 'src/static/');
|
|||
const LIBRARY_WHITELIST = [
|
||||
'async',
|
||||
'js-cookie',
|
||||
'security',
|
||||
'split-grid',
|
||||
'tinycon',
|
||||
'underscore',
|
||||
|
|
|
|||
|
|
@ -78,7 +78,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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,20 +1,73 @@
|
|||
// @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;
|
||||
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;
|
||||
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;
|
||||
export const encodeJavaScriptIdentifier = (text: string) => text && text.replace(JAVASCRIPT_CHARACTERS_EXPRESSION,
|
||||
(c: string) => `\\u${(`0000${c.charCodeAt(0).toString(16)}`).slice(-4)}`);
|
||||
|
||||
export const encodeJavaScriptString = (text: string) => text && `"${encodeJavaScriptIdentifier(text)}"`;
|
||||
|
||||
// This is not great, but it is useful.
|
||||
// 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)));
|
||||
|
||||
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
|
||||
const CSS_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm;
|
||||
export const encodeCSSIdentifier = (text: string) => text && text.replace(CSS_CHARACTERS_EXPRESSION,
|
||||
(c: string) => `\\${(`000000${c.charCodeAt(0).toString(16)}`).slice(-6)}`);
|
||||
|
||||
export const encodeCSSString = (text: string) => text && `"${encodeCSSIdentifier(text)}"`;
|
||||
|
|
|
|||
78
src/tests/backend/specs/security.ts
Normal file
78
src/tests/backend/specs/security.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
'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">/&\''),
|
||||
'<a href="x">/&'');
|
||||
});
|
||||
it('neutralises a script tag', function () {
|
||||
assert.equal(Security.escapeHTML('<script>alert(1)</script>'),
|
||||
'<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<b'), '"a\\u003cb"');
|
||||
});
|
||||
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: '<b>', 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue