Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 5 additions & 13 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion src/node/utils/Minify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const ROOT_DIR = path.join(settings.root, 'src/static/');
const LIBRARY_WHITELIST = [
'async',
'js-cookie',
'security',
'split-grid',
'tinycon',
'underscore',
Expand Down
1 change: 0 additions & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/static/js/pad_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down
77 changes: 65 additions & 12 deletions src/static/js/security.ts
Original file line number Diff line number Diff line change
@@ -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} = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
};

// 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 changes: 78 additions & 0 deletions src/tests/backend/specs/security.ts
Original file line number Diff line number Diff line change
@@ -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">/&\''),
'&lt;a href=&quot;x&quot;&gt;&#x2F;&amp;&#x27;');
});
it('neutralises a script tag', function () {
assert.equal(Security.escapeHTML('<script>alert(1)</script>'),
'&lt;script&gt;alert(1)&lt;&#x2F;script&gt;');
});
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&#x20;b');
// hex is lowercased, matching the original `security` package output.
assert.equal(Security.escapeHTMLAttribute('javascript:alert(1)'),
'javascript&#x3a;alert&#x28;1&#x29;');
});
it('prefers named entities for &, <, >, ", \', /', function () {
assert.equal(Security.escapeHTMLAttribute('<>&"\'/'),
'&lt;&gt;&amp;&quot;&#x27;&#x2F;');
});
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