mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-11 01:36:35 +00:00
fix(triage): defuse mentions with ZWSP and track HTML blocks in sanitizer (#8147)
This commit is contained in:
parent
289df621ce
commit
b0ae340b1c
2 changed files with 58 additions and 14 deletions
31
.github/workflows/qwen-triage.yml
vendored
31
.github/workflows/qwen-triage.yml
vendored
|
|
@ -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 @ — 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 (@​) — renders
|
||||
# identically, never fires a mention (GitHub decodes @
|
||||
# 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, "<")
|
||||
.replace(/<(\/?)(details|summary)>/g, "<$1$2>")
|
||||
.replace(/<!--/g, "<!\\-\\-")
|
||||
.replace(/@/g, "@");
|
||||
.replace(/@/g, "@​");
|
||||
}
|
||||
function escCode(s) {
|
||||
return s.replace(/<!--/g, "<!\\-\\-");
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 @ 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('@everyone');
|
||||
expect(prose).toContain('@​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('<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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue