mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 03:32:51 +08:00
228 lines
9.8 KiB
JavaScript
228 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Title escaping and subscription-URL parsing.
|
|
*
|
|
* Two sinks with different properties, which is the whole point of this test:
|
|
*
|
|
* - A form *tab* title is a bare string child of E('a', ...), and LuCI's
|
|
* dom.append() assigns a bare string to innerHTML. One decode, so one
|
|
* level of escaping is both necessary and sufficient.
|
|
*
|
|
* - A GridSection *modal* title goes through form.titleFn(), which runs the
|
|
* value through stripTags(). stripTags' documented contract is "HTML tags
|
|
* removed, and HTML entities DECODED" - it parses `<div>${s}</div>` and
|
|
* returns textContent - and the decoded result is then handed to the same
|
|
* innerHTML sink. Escaping is therefore not enough there, and is in fact
|
|
* worse than nothing: `<img>` would be decoded back into markup.
|
|
*
|
|
* The test drives the same two decodes the framework performs, so a value that
|
|
* would become markup in the browser fails here instead.
|
|
*
|
|
* Usage: node tests/frontend-title-escaping.js <repo-root>
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
|
|
const { loadLuciModule } = require('./lib/luci-module.js');
|
|
|
|
const root = path.resolve(process.argv[2] || '.');
|
|
|
|
let checks = 0, failures = 0;
|
|
function check(what, ok, detail) {
|
|
checks++;
|
|
if (ok) return;
|
|
failures++;
|
|
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
|
}
|
|
|
|
/* uci.get is what loadModalTitle reads the label through; keep it mutable so
|
|
* the test can present a hostile label. */
|
|
let label = null;
|
|
const hp = loadLuciModule(
|
|
path.join(root, 'htdocs/luci-static/resources/homeproxy-pro.js'),
|
|
{ baseclass: { extend: (o) => o }, form: { DynamicList: { extend: (o) => o } },
|
|
fs: {}, rpc: {}, ui: {},
|
|
uci: { get: (_c, _s, o) => (o === 'label' ? label : null) } });
|
|
|
|
/* Models what form.stripTags() does to a string: parse it as the innerHTML of
|
|
* a <div>, then read the text back. For the entities that matter here that is
|
|
* exactly one level of entity decoding - which is why it turns '<' into the
|
|
* character '<'. Only the five named entities and numeric character
|
|
* references are modelled; that is enough, because those are the only ways to
|
|
* spell '<' without writing it. */
|
|
function stripTags(s) {
|
|
if (typeof s == 'string' && !/[<>&]/.test(s))
|
|
return s;
|
|
|
|
/* A raw tag is dropped by the parse, which is what makes a *raw* '<...>'
|
|
* label safe today; only the text outside tags survives. */
|
|
const withoutTags = s.replace(/<[^>]*>/g, '');
|
|
|
|
return decodeEntitiesOnce(withoutTags);
|
|
}
|
|
|
|
function decodeEntitiesOnce(s) {
|
|
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
|
|
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
|
|
if (body[0] === '#') {
|
|
const code = body[1] === 'x' || body[1] === 'X'
|
|
? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
|
|
}
|
|
return Object.prototype.hasOwnProperty.call(named, body) ? named[body] : m;
|
|
});
|
|
}
|
|
|
|
/* The property that decides whether assigning a string to innerHTML can start
|
|
* a tag: no literal '<' may survive stripTags' single decode.
|
|
*
|
|
* Two callers, deliberately kept apart, because conflating them hides bugs:
|
|
* rendersAsMarkupAfterEscape() takes a RAW value and applies the sanitizer
|
|
* first, so it tests the sanitizer; titleBecomesMarkup() takes a value that
|
|
* has already been sanitized (the output of loadModalTitle) and must NOT
|
|
* sanitize it again - doing so would mask a call site that forgot to. */
|
|
function titleBecomesMarkup(alreadyEscaped) {
|
|
return stripTags(alreadyEscaped).includes('<');
|
|
}
|
|
|
|
function rendersAsMarkupAfterEscape(raw) {
|
|
return titleBecomesMarkup(hp.escapeTitleText(raw));
|
|
}
|
|
|
|
const PAYLOADS = [
|
|
'<img src=x onerror=alert(1)>', // the reported vector
|
|
'<img src=x onerror=alert(1)>', // raw tag: safe before, must stay safe
|
|
'&lt;script&gt;alert(1)&lt;/script&gt;',
|
|
'<script>alert(1)</script>',
|
|
'<script>',
|
|
'"><script>alert(1)</script>',
|
|
"'><svg onload=alert(1)>",
|
|
'&',
|
|
'&<<',
|
|
'a < b > c'
|
|
];
|
|
|
|
for (const p of PAYLOADS) {
|
|
check(`escapeTitleText leaves no live markup for ${JSON.stringify(p)}`,
|
|
!rendersAsMarkupAfterEscape(p),
|
|
`stripTags(escapeTitleText(x)) = ${JSON.stringify(stripTags(hp.escapeTitleText(p)))}`);
|
|
}
|
|
|
|
/* And it must not mangle ordinary names. */
|
|
for (const name of ['Tokyo-01', '香港 01', 'a & b', 'node (backup)', 'im?possible', '100%']) {
|
|
check(`escapeTitleText keeps ${JSON.stringify(name)} readable`,
|
|
stripTags(hp.escapeTitleText(name)) === name,
|
|
`got ${JSON.stringify(stripTags(hp.escapeTitleText(name)))}`);
|
|
}
|
|
|
|
/* escapeHtml is the one-decode sink: it must not emit a literal '<' at all,
|
|
* and exactly one decode must give the original back - no more (the value is
|
|
* fully escaped) and no less (it is escaped deeply enough that a single decode
|
|
* cannot re-introduce markup).
|
|
*
|
|
* This check used to read `!decodeEntitiesOnce(escaped).includes('<') || true`,
|
|
* which is unconditionally true, so it never checked anything: for the raw-tag
|
|
* payloads one decode *does* contain '<' (that is what a one-level escape
|
|
* means), and the `|| true` was papering over the wrong property. */
|
|
for (const p of PAYLOADS) {
|
|
const escaped = hp.escapeHtml(p);
|
|
check(`escapeHtml emits no literal '<' for ${JSON.stringify(p)}`,
|
|
!escaped.includes('<') && !escaped.includes('>'),
|
|
JSON.stringify(escaped));
|
|
const decoded = decodeEntitiesOnce(escaped);
|
|
check(`escapeHtml survives exactly one decode for ${JSON.stringify(p)}`,
|
|
decoded === p,
|
|
`one decode gave ${JSON.stringify(decoded)}, expected ${JSON.stringify(p)}`);
|
|
}
|
|
|
|
/* The reason escapeTitleText exists at all: on the *raw* tag case, plain
|
|
* escaping hands stripTags something it decodes straight back into markup.
|
|
* This is not a defect in escapeHtml - it is the wrong tool for this sink. */
|
|
check('escapeHtml would be unsafe for the modal title (raw tag decoded back)',
|
|
stripTags(hp.escapeHtml('<img src=x onerror=alert(1)>')).includes('<'),
|
|
'if this ever stops being true the comment above is stale');
|
|
|
|
/* loadModalTitle must use the stripTags-aware escape on the label. */
|
|
for (const p of ['<img src=x onerror=alert(1)>', '<img src=x onerror=alert(1)>']) {
|
|
label = p;
|
|
const title = hp.loadModalTitle('Node', 'Add', 'homeproxy-pro', 'n1');
|
|
check(`loadModalTitle escapes the label ${JSON.stringify(p)}`,
|
|
!titleBecomesMarkup(title),
|
|
`title = ${JSON.stringify(title)} -> ${JSON.stringify(stripTags(title))}`);
|
|
}
|
|
label = null;
|
|
check('loadModalTitle falls back to addtitle without a label',
|
|
hp.loadModalTitle('Node', 'Add', 'homeproxy-pro', 'n1') === 'Add');
|
|
|
|
/* --- subscriptionInfo: the crash and the tab-title sink ------------------ */
|
|
|
|
/* A fragment of '#%' or '#100%' is accepted by the form validator (it only
|
|
* needs new URL() to succeed and a hostname to exist) but makes
|
|
* decodeURIComponent throw. That threw out of render() before m.render(),
|
|
* so the whole Node Settings page failed to render. */
|
|
for (const u of ['https://h/p#%', 'https://h/p#100%', 'https://h/p#%zz', 'https://h/p#a%']) {
|
|
let info = null, threw = null;
|
|
try { info = hp.subscriptionInfo(u); } catch (e) { threw = e; }
|
|
check(`subscriptionInfo does not throw on ${u}`, threw === null, threw && threw.message);
|
|
check(`subscriptionInfo still returns a hash for ${u}`,
|
|
info && typeof info.hash === 'string' && info.hash.length === 32,
|
|
info && JSON.stringify(info.hash));
|
|
/* The fallback has to be the raw fragment, not the whole URL: a bare
|
|
* decodeURIComponent() would be caught by the outer guard and display
|
|
* 'https://h/p#100%' in the tab, which reads as a bug to the user. */
|
|
check(`subscriptionInfo shows the raw fragment for ${u}`,
|
|
info && info.title === u.slice(u.indexOf('#') + 1),
|
|
info && JSON.stringify(info.title));
|
|
}
|
|
|
|
/* A value UCI may hold whatever another package wrote. */
|
|
for (const junk of ['not a url', '', null, undefined, 42, {}]) {
|
|
let threw = null;
|
|
try { hp.subscriptionInfo(junk); } catch (e) { threw = e; }
|
|
check(`subscriptionInfo tolerates ${JSON.stringify(junk)}`, threw === null, threw && threw.message);
|
|
}
|
|
check('subscriptionInfo returns null for an empty string', hp.subscriptionInfo('') === null);
|
|
check('subscriptionInfo returns null for a non-string', hp.subscriptionInfo(42) === null);
|
|
|
|
/* The tab-title path renders the fragment through innerHTML. Wrapped so a
|
|
* regression fails with a named check instead of a stack trace. */
|
|
function info(label, url) {
|
|
try {
|
|
return hp.subscriptionInfo(url);
|
|
}
|
|
catch (e) {
|
|
check(`subscriptionInfo must not throw for ${label}`, false, `${e.name}: ${e.message}`);
|
|
return { hash: '', title: '' };
|
|
}
|
|
}
|
|
|
|
const hostile = info('a raw-tag fragment', 'https://e.com/#<img src=x onerror=alert(1)>');
|
|
check('subscriptionInfo escapes a raw-tag fragment',
|
|
!hostile.title.includes('<') && !hostile.title.includes('>'),
|
|
JSON.stringify(hostile.title));
|
|
|
|
const encoded = info('an entity-encoded fragment', 'https://e.com/#<img src=x onerror=alert(1)>');
|
|
check('subscriptionInfo escapes an entity-encoded fragment',
|
|
!encoded.title.includes('<') && !encoded.title.includes('>'),
|
|
JSON.stringify(encoded.title));
|
|
|
|
const named = info('a percent-encoded fragment', 'https://e.com/#Tokyo%20%26%20Osaka');
|
|
check('subscriptionInfo keeps %26 as & (one decode, then escape)',
|
|
named.title === 'Tokyo & Osaka', JSON.stringify(named.title));
|
|
|
|
const plain = info('a plain URL', 'https://sub.example.com/path');
|
|
check('subscriptionInfo falls back to the hostname without a fragment',
|
|
plain.title === 'sub.example.com', JSON.stringify(plain.title));
|
|
|
|
/* The hash must ignore the fragment, so two URLs that differ only by fragment
|
|
* map to the same subscription. */
|
|
check('subscriptionInfo hashes the URL without its fragment',
|
|
info('a fragment x', 'https://e.com/a#x').hash === info('a fragment y', 'https://e.com/a#y').hash);
|
|
|
|
console.log(`frontend title escaping: ${checks} checks, ${failures} failures`);
|
|
process.exit(failures ? 1 : 0);
|