fix(llm-security): v8.x-A - close <script> recall hole, add whole-table ReDoS gate
Two of the three confirmed v8.x-A evasions are closed; the third (attribute padding) stays open by operator decision and is documented, not silently left. Recall fix. hybrid-xss required a closing </script>, so `<script>alert(1)` and `<script src=x.js>` both passed scanForInjection() with found: false, while the closed form returned high. A src= tag has no body to close in the first place. The opening tag alone is the signal, and matching it is strictly linear: one negated-class run whose excluded character is its own terminator, so there is no backtracking surface that could re-introduce v7.8.3 #24. ReDoS gate. The #24 test covered six html-obfuscation patterns against the two shapes that defect was found on. A catastrophically backtracking regex added anywhere else in the four tables would have failed no test at all - which is why #24 had to be found by hand. The new gate times every exported pattern and asserts its own coverage, so it cannot be narrowed silently. It has two classes because the blowup shapes need opposite inputs, and because a synchronous RegExp.test() cannot be interrupted: an exponential pattern met with a 512KB input would HANG the run rather than fail it. Class 1 uses a 28-char ambiguous-run ladder, where exponential costs ~1s and anything sane costs microseconds; on a hit the 512KB sweeps refuse to run. Class 2 uses the hook's real read cap to catch the polynomial #24 class. Proven to fire: an injected exponential regex was named by label and the hang guard held. Measured clean: worst single pattern 17ms, full large sweep 218ms. Golden gate went red as expected and was re-blessed - diff is exactly two lines, the regex source and the source-file digest. The 61-payload reference run is byte-identical, so no showcase behaviour moved. Suite 2073/2073 (was 2063; +5 recall tests, +5 gate tests). Still open, unchanged: all 7 bounded HTML patterns evade on >256 chars of padding, in two positions - before the attribute and inside the style value. Wider than STATE recorded, which named only aria-label.
This commit is contained in:
parent
b0de0ca6d8
commit
90f576f056
3 changed files with 203 additions and 3 deletions
|
|
@ -162,7 +162,12 @@ export const HYBRID_PATTERNS = [
|
|||
{ pattern: /(?:when|if)\s+(?:the\s+)?(?:user|human|operator)\s+(?:asks?|requests?|queries)[^.]{0,60}(?:respond\s+with|output|reply\s+with|include)\s+(?:this|the\s+following)/i, label: 'hybrid-recursive: conditional response injection (recursive payload)' },
|
||||
|
||||
// XSS in agent context: script/event handlers in content for markdown rendering
|
||||
{ pattern: /<script\b[^>]*>[\s\S]*?<\/script>/i, label: 'hybrid-xss: <script> tag in content (agent context XSS)' },
|
||||
// v8.x-A: the closing </script> requirement was a recall hole — `<script>alert(1)`
|
||||
// and `<script src=x.js>` both passed with found: false. The opening tag alone is
|
||||
// the signal; a src= tag has no body to close in the first place. Matching the
|
||||
// open tag is also strictly linear: one negated-class run whose excluded char
|
||||
// is the terminator, so there is no backtracking surface to re-introduce #24.
|
||||
{ pattern: /<script\b[^>]*>/i, label: 'hybrid-xss: <script> tag in content (agent context XSS)' },
|
||||
{ pattern: /javascript\s*:/i, label: 'hybrid-xss: javascript: URI scheme (agent context XSS)' },
|
||||
{ pattern: /\bon(?:error|load|click|mouseover|focus|blur)\s*=/i, label: 'hybrid-xss: inline event handler attribute (agent context XSS)' },
|
||||
{ pattern: /<iframe\b[^>]*src\s*=\s*["'][^"']*(?:javascript:|data:text\/html)/i, label: 'hybrid-xss: iframe with executable src (agent context XSS)' },
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@
|
|||
{
|
||||
"kind": "regex",
|
||||
"key": "injection-patterns:HYBRID_PATTERNS[4].pattern",
|
||||
"source": "<script\\b[^>]*>[\\s\\S]*?<\\/script>",
|
||||
"source": "<script\\b[^>]*>",
|
||||
"flags": "i"
|
||||
},
|
||||
{
|
||||
|
|
@ -560,7 +560,7 @@
|
|||
{
|
||||
"kind": "file",
|
||||
"key": "scanners/lib/injection-patterns.mjs",
|
||||
"sha256": "0dda47e385e5a0bc33b83f7e33bb546544302907bc830afb0421680a2daff4ef"
|
||||
"sha256": "9965d51aca86b4837181920064ce3af23e9baaf958979eef51ed2ee8f8f9d078"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
|
|
|
|||
|
|
@ -1098,6 +1098,46 @@ describe('scanForInjection — hybrid XSS in agent context (v5.0 S6)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v8.x-A — <script> recall hole: the opening tag is the signal
|
||||
//
|
||||
// The v5.0 pattern required a closing </script>, so an attacker who simply
|
||||
// omitted it — or who used a src= tag that has no text content to close —
|
||||
// passed scanForInjection() with found: false. Measured 2026-08-10 against
|
||||
// the whole engine, not just the table. The opening tag alone is what marks
|
||||
// content as carrying executable script in an agent-rendered context.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('scanForInjection — unclosed <script> recall (v8.x-A)', () => {
|
||||
it('detects <script> with no closing tag', () => {
|
||||
const { high, found } = scanForInjection('<script>alert(1)');
|
||||
assert.ok(found, 'unclosed <script> must not pass unflagged');
|
||||
assert.ok(high.some(h => h.includes('hybrid-xss') && h.includes('script')));
|
||||
});
|
||||
|
||||
it('detects <script src=...> with no body and no closing tag', () => {
|
||||
const { high, found } = scanForInjection('<script src=x.js>');
|
||||
assert.ok(found, 'remote-src <script> must not pass unflagged');
|
||||
assert.ok(high.some(h => h.includes('hybrid-xss') && h.includes('script')));
|
||||
});
|
||||
|
||||
it('still detects the closed form (v5.0 behaviour preserved)', () => {
|
||||
const { high } = scanForInjection('<script>alert(document.cookie)</script>');
|
||||
assert.ok(high.some(h => h.includes('hybrid-xss') && h.includes('script')));
|
||||
});
|
||||
|
||||
it('does NOT trigger on prose naming the script element', () => {
|
||||
const { high } = scanForInjection('The script element is used for client-side JavaScript code.');
|
||||
assert.equal(high.filter(h => h.includes('hybrid-xss')).length, 0);
|
||||
});
|
||||
|
||||
it('does NOT trigger on a word starting with "script"', () => {
|
||||
const { high } = scanForInjection('See <scripts-guide> for the full reference.');
|
||||
assert.equal(high.filter(h => h.includes('hybrid-xss') && h.includes('script')).length, 0,
|
||||
'\\b must keep <scripts-guide> from matching <script>');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E3 — rot13 layer for comment-block injection (v7.3.0 / Batch C)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1192,3 +1232,158 @@ describe('HTML-obfuscation patterns — ReDoS resistance (v7.8.3 #24)', () => {
|
|||
assert.ok(high.some((h) => h.includes('aria-label')), 'expected aria-label hit');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v8.x-A — whole-table ReDoS gate
|
||||
//
|
||||
// The v7.8.3 #24 gate above covers six html-obfuscation patterns against the
|
||||
// two shapes that defect was found on. That is a regression test for one bug,
|
||||
// not a gate: a catastrophically backtracking regex added anywhere else in the
|
||||
// four tables would fail no test at all. #24 was found by hand, and hand-
|
||||
// auditing is exactly what does not survive the next edit.
|
||||
//
|
||||
// This gate times EVERY exported pattern, and asserts its own coverage so it
|
||||
// cannot be narrowed silently. Two classes, because the two blowup shapes need
|
||||
// opposite inputs — and because a synchronous RegExp.test() cannot be
|
||||
// interrupted, so an exponential pattern met with a 512KB input would HANG the
|
||||
// run instead of failing it:
|
||||
//
|
||||
// 1. Exponential (ambiguous overlapping quantifiers, e.g. /(?:[a-z]+ ?)*x/).
|
||||
// Detected on a TINY ladder: doubling every ~2 chars means 28 characters
|
||||
// already costs ~1s while any sane pattern costs microseconds. Bounded
|
||||
// cost, no hang risk. This class runs FIRST and, on a hit, the large-input
|
||||
// sweeps below refuse to run rather than wedge the suite.
|
||||
// 2. Polynomial (the #24 class: overlapping bounded runs plus a required
|
||||
// terminator the attacker omits). Only visible at scale, so this class
|
||||
// uses the hook's real 512KB read cap.
|
||||
//
|
||||
// Measured 2026-08-10 with the tables clean: worst single pattern 17ms, whole
|
||||
// large sweep 218ms. The budgets sit far above that noise floor and far below
|
||||
// anything a real ReDoS produces. Proven to fire — see tests/golden/README.md
|
||||
// convention: a gate that has never been red is an assertion, not a gate.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('injection patterns — whole-table ReDoS gate (v8.x-A)', () => {
|
||||
const ALL_PATTERNS = [
|
||||
...CRITICAL_PATTERNS,
|
||||
...HIGH_PATTERNS,
|
||||
...MEDIUM_PATTERNS,
|
||||
...HYBRID_PATTERNS,
|
||||
];
|
||||
|
||||
// Class 1 — short inputs made of ambiguously-splittable runs. Short enough
|
||||
// that an exponential pattern reports instead of hanging.
|
||||
const AMBIGUOUS = {
|
||||
'word-run-in-tag': '<div ' + 'a'.repeat(28),
|
||||
'spaced-word-run': 'ignore ' + 'a '.repeat(14),
|
||||
'quoted-pair-run': '<a href="' + 'ab'.repeat(14),
|
||||
'digit-run': '<p style="left:-' + '9'.repeat(28),
|
||||
};
|
||||
const AMBIGUOUS_BUDGET_MS = 100;
|
||||
|
||||
// Class 2 — the hook reads at most 512KB, so that is the attacker's budget.
|
||||
const READ_CAP = 512 * 1024;
|
||||
const grow = (unit, cap = READ_CAP) =>
|
||||
unit.repeat(Math.ceil(cap / unit.length)).slice(0, cap);
|
||||
|
||||
const PATHOLOGICAL = {
|
||||
'unclosed-style-quote': '<div style="' + grow('display:none;'),
|
||||
'multi-keyword-style':
|
||||
'<div style="' +
|
||||
grow(
|
||||
'display:none;visibility:hidden;position:absolute; left:-9999px;' +
|
||||
'font-size: 0;opacity: 0;height:0;overflow:hidden;',
|
||||
64 * 1024,
|
||||
),
|
||||
'unclosed-attr-quote': '<a aria-label="' + grow('a'),
|
||||
'unclosed-tag': '<div ' + grow('a'),
|
||||
'whitespace-run': 'ignore' + grow(' '),
|
||||
'repeated-trigger': grow('ignore all previous '),
|
||||
'sql-nearmiss': grow('ignore SELECT '),
|
||||
'angle-soup': grow('<a b="c"><!-- x '),
|
||||
};
|
||||
const PER_PATTERN_BUDGET_MS = 250;
|
||||
const TOTAL_BUDGET_MS = 2000;
|
||||
|
||||
const timeMs = (pattern, text) => {
|
||||
const started = process.hrtime.bigint();
|
||||
pattern.lastIndex = 0;
|
||||
pattern.test(text);
|
||||
return Number(process.hrtime.bigint() - started) / 1e6;
|
||||
};
|
||||
|
||||
// Populated by the class-1 test; read by the class-2 tests as a hang guard.
|
||||
const exponentialOffenders = [];
|
||||
|
||||
it('covers every exported pattern, not a hand-picked subset', () => {
|
||||
// Guards the gate itself: if a fifth table is exported, or this list is
|
||||
// trimmed, the mismatch surfaces here rather than as a silent blind spot.
|
||||
const expected =
|
||||
CRITICAL_PATTERNS.length +
|
||||
HIGH_PATTERNS.length +
|
||||
MEDIUM_PATTERNS.length +
|
||||
HYBRID_PATTERNS.length;
|
||||
assert.equal(ALL_PATTERNS.length, expected);
|
||||
assert.ok(ALL_PATTERNS.length >= 80,
|
||||
`expected the full table, got ${ALL_PATTERNS.length}`);
|
||||
});
|
||||
|
||||
it('no pattern blows up exponentially on ambiguous runs', () => {
|
||||
for (const [shape, text] of Object.entries(AMBIGUOUS)) {
|
||||
for (const { pattern, label } of ALL_PATTERNS) {
|
||||
const ms = timeMs(pattern, text);
|
||||
if (ms > AMBIGUOUS_BUDGET_MS) {
|
||||
exponentialOffenders.push(`${shape} :: ${label} :: ${ms.toFixed(0)}ms on ${text.length} chars`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(exponentialOffenders, [],
|
||||
`exponential backtracking on a ${Object.values(AMBIGUOUS)[0].length}-char input:\n ${exponentialOffenders.join('\n ')}`);
|
||||
});
|
||||
|
||||
it('no single pattern backtracks polynomially at the 512KB read cap', () => {
|
||||
assert.deepEqual(exponentialOffenders, [],
|
||||
'refusing to run 512KB input against an exponential pattern — fix the class-1 failure first');
|
||||
const offenders = [];
|
||||
for (const [shape, text] of Object.entries(PATHOLOGICAL)) {
|
||||
for (const { pattern, label } of ALL_PATTERNS) {
|
||||
const ms = timeMs(pattern, text);
|
||||
if (ms > PER_PATTERN_BUDGET_MS) {
|
||||
offenders.push(`${shape} :: ${label} :: ${ms.toFixed(0)}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [],
|
||||
`patterns exceeded ${PER_PATTERN_BUDGET_MS}ms on pathological input:\n ${offenders.join('\n ')}`);
|
||||
});
|
||||
|
||||
it('the whole table sweeps every shape within budget', () => {
|
||||
assert.deepEqual(exponentialOffenders, [],
|
||||
'refusing to run 512KB input against an exponential pattern — fix the class-1 failure first');
|
||||
const started = process.hrtime.bigint();
|
||||
for (const text of Object.values(PATHOLOGICAL)) {
|
||||
for (const { pattern } of ALL_PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
pattern.test(text);
|
||||
}
|
||||
}
|
||||
const ms = Number(process.hrtime.bigint() - started) / 1e6;
|
||||
assert.ok(ms < TOTAL_BUDGET_MS,
|
||||
`full-table sweep took ${ms.toFixed(0)}ms, budget ${TOTAL_BUDGET_MS}ms`);
|
||||
});
|
||||
|
||||
it('scanForInjection itself terminates on the pathological corpus', () => {
|
||||
assert.deepEqual(exponentialOffenders, [],
|
||||
'refusing to run 512KB input against an exponential pattern — fix the class-1 failure first');
|
||||
// The tables are one thing; the engine wraps them with decode passes and
|
||||
// the cognitive-load check, and that composition is what the hook calls.
|
||||
const started = process.hrtime.bigint();
|
||||
for (const text of Object.values(PATHOLOGICAL)) {
|
||||
scanForInjection(text);
|
||||
}
|
||||
const ms = Number(process.hrtime.bigint() - started) / 1e6;
|
||||
assert.ok(ms < 10_000,
|
||||
`scanForInjection took ${ms.toFixed(0)}ms across the pathological corpus`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue