fix(triage): defuse mentions with ZWSP and track HTML blocks in sanitizer (#8147)

This commit is contained in:
qwen-code-ci-bot 2026-07-31 11:03:31 +00:00
parent 289df621ce
commit b0ae340b1c
2 changed files with 58 additions and 14 deletions

View file

@ -3342,8 +3342,15 @@ jobs:
# on four line-independent guarantees:
# 1. in PROSE every < is escaped, then only the structural tags
# the report uses as raw HTML (details/summary) are un-escaped
# back to live tags — no other tag can form, so
# <img>/<script>/onerror never render. & and > are left alone
# back to live tags — no other tag can form in ordinary
# prose, so <img>/<script>/onerror never render there. A
# line-level inHtml flag tracks raw-HTML blocks opened by
# details/summary: inside one, backtick lines are NOT fences
# (CommonMark/GitHub reads them as literal text), so the
# sanitizer prose-escapes their content instead of passing it
# through escCode — closing the divergence where GitHub saw
# live HTML the sanitizer treated as inert code. & and > are
# left alone
# in prose (a decoded entity yields text, never markup, and an
# unescaped > cannot open a tag once < is escaped), which keeps
# &&, ->, and blockquotes readable. Inside code spans/fences
@ -3355,9 +3362,12 @@ jobs:
# neutralizer). The break must stay global: the upsert greps
# the RAW body for the running marker and a fence prints
# verbatim, so a forged marker inside a fence must not survive;
# 3. in prose @ becomes &#64; — renders identically, never fires a
# mention (a mention cannot fire under a code/pre ancestor, so
# code keeps a literal @);
# 3. in prose @ gains a zero-width space (@&#8203;) — renders
# identically, never fires a mention (GitHub decodes &#64;
# back to @ before the mention filter runs, so the entity
# alone was inert; the ZWSP breaks the mention token). A
# mention cannot fire under a code/pre ancestor, so code
# keeps a literal @;
# 4. <details> folds are balanced over PROSE only — a </details>
# quoted in a code span/fence is inert text and is no longer
# counted — surplus closers are dropped (they would otherwise
@ -3395,7 +3405,7 @@ jobs:
.replace(/</g, "&lt;")
.replace(/&lt;(\/?)(details|summary)>/g, "<$1$2>")
.replace(/&lt;!--/g, "&lt;!\\-\\-")
.replace(/@/g, "&#64;");
.replace(/@/g, "@&#8203;");
}
function escCode(s) {
return s.replace(/<!--/g, "&lt;!\\-\\-");
@ -3437,16 +3447,19 @@ jobs:
}
const lines = text.split("\n");
const out = [];
let inFence = false, fc = "", fl = 0;
let inFence = false, fc = "", fl = 0, inHtml = false;
for (const line of lines) {
if (!inFence) {
const m = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (inHtml && /^\s*$/.test(line)) inHtml = false;
const m = inHtml ? null : line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (m && !(m[1][0] === "`" && m[2].indexOf("`") !== -1)) {
inFence = true; fc = m[1][0]; fl = m[1].length;
out.push(escCode(line));
continue;
}
out.push(proseLine(line));
const rendered = proseLine(line);
if (/^ {0,3}<\/?(details|summary)\b/.test(rendered)) inHtml = true;
out.push(rendered);
} else {
const cm = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
if (cm && cm[1][0] === fc && cm[1].length >= fl) inFence = false;

View file

@ -1599,15 +1599,18 @@ describe('qwen-triage verify hardening', () => {
let inFence = false;
let fc = '';
let fl = 0;
let inHtml = false;
for (const line of s.split('\n')) {
if (!inFence) {
const m = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (inHtml && /^\s*$/.test(line)) inHtml = false;
const m = inHtml ? null : line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
inFence = true;
fc = m[1][0];
fl = m[1].length;
continue;
}
if (/^ {0,3}<\/?(details|summary)\b/.test(line)) inHtml = true;
kept.push(line.replace(/`[^`]*`/g, ''));
} else {
const cm = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/);
@ -1692,12 +1695,14 @@ describe('qwen-triage verify hardening', () => {
// anywhere (broken globally, prose AND code), so the upsert grep for
// the running marker cannot be forged from a fenced quote.
expect(out).not.toContain('<!--');
// Security floor over PROSE: the mention is neutralized, no live
// non-allowlisted tag, and prose folds balance (the fenced </details>
// is NOT counted, so the genuinely unclosed fold still gets closed).
// Security floor over PROSE: the mention is neutralized by a ZWSP
// (GitHub decodes &#64; before the mention filter, so the entity alone
// was inert), no live non-allowlisted tag, and prose folds balance
// (the fenced </details> is NOT counted, so the genuinely unclosed
// fold still gets closed).
const prose = stripCode(out);
expect(prose).not.toContain('@everyone');
expect(prose).toContain('&#64;everyone');
expect(prose).toContain('@&#8203;everyone');
expect(prose).not.toContain('<img');
expect(prose.match(/<(?!\/?(details|summary)\b)[A-Za-z]/g)).toBe(null);
const opens = prose.split('<details>').length - 1;
@ -1719,6 +1724,32 @@ describe('qwen-triage verify hardening', () => {
holeProse.split('</details>').length,
);
// HTML-block divergence: a fence opener inside a <details> HTML
// block is literal text per CommonMark/GitHub, not a fence. The
// sanitizer must prose-escape the content (neutralizing <img>)
// rather than passing it through escCode as inert code.
const htmlblk = join(dir, 'htmlblk.md');
writeFileSync(
htmlblk,
[
'<details>',
'<summary>fold</summary>',
'```',
'<img src=x onerror=alert(1)>',
'```',
'</details>',
'',
].join('\n'),
);
const htmlOut = emit(htmlblk, 45000);
// The <img> is prose-escaped, not passed through as inert code.
expect(htmlOut).toContain('&lt;img src=x onerror=alert(1)>');
expect(htmlOut).not.toContain('<img');
// The fold balances: wrapper 1 open / 1 close, report fold balanced.
expect(htmlOut.split('<details>').length - 1).toBe(
htmlOut.split('</details>').length - 1,
);
// Mirror case: a surplus </details> with no open is dropped so it
// cannot close the wrapping fold early — the wrapper stays 1 open /
// 1 close even though the report shipped an orphan closer.