diff --git a/changelog.d/gst-940-style-charref.md b/changelog.d/gst-940-style-charref.md
new file mode 100644
index 000000000..2e543b25e
--- /dev/null
+++ b/changelog.d/gst-940-style-charref.md
@@ -0,0 +1,3 @@
+### Fixed
+
+- **A character reference or an HTML comment end can no longer hide CSS in an SVG chart (GST-940).** `</style>` or `<!--` inside a `` nor a comment that only an HTML parser ends early can hide CSS (GST-940); anything else, a
script or `foreignObject` included, keeps it code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
diff --git a/lib/review-depth.ts b/lib/review-depth.ts
index 59340ac8e..b59862771 100644
--- a/lib/review-depth.ts
+++ b/lib/review-depth.ts
@@ -98,9 +98,10 @@ const SVG_HREF = /^\s*(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+
* Whether an SVG's whole text is a drawing (GST-911): every element in SVG_ELEMENTS, no event handler, every href or src a
* same-document reference or an inline raster image, CSS of declarations only, and nothing SVG_REFUSED names, read after character references are
* decoded. Every name= in the text is read, so a stray quote cannot hide a later attribute inside its value; an unquoted value
- * stops at the next =, which keeps the scan linear. A style element's CSS is walked with indexOf to its closing tag, dropping
- * comments and reading CDATA as CSS, so neither can hide a closing tag; any other markup inside it, or a style element that never
- * closes, refuses. Anything else, an SVG that can run code included, stays code.
+ * stops at the next =, which keeps the scan linear. A style element's CSS is walked with indexOf to its closing tag over the raw
+ * text, so a character reference cannot fake a closing tag or a comment, and any & in it refuses (GST-940). Comments and CDATA are
+ * read as CSS, so neither can hide a closing tag and an HTML-only comment end ( or --!>) exposes nothing unread; any other
+ * markup inside it, or a style element that never closes, refuses. Anything else, an SVG that can run code included, stays code.
*/
export function svgDrawing(text: string): boolean {
const t = decodeCharRefs(text);
@@ -108,29 +109,27 @@ export function svgDrawing(text: string): boolean {
const cssDrawing = (css: string) => !SVG_CSS_REFUSED.test(css.replace(SVG_CSS_REF, ''));
for (let at = 0; ;) {
SVG_STYLE_OPEN.lastIndex = at;
- const open = SVG_STYLE_OPEN.exec(t);
+ const open = SVG_STYLE_OPEN.exec(text);
if (!open) break;
- let i = t.indexOf('>', open.index) + 1;
+ let i = text.indexOf('>', open.index) + 1;
if (!i) return false;
let css = '';
for (;;) {
- const lt = t.indexOf('<', i);
+ const lt = text.indexOf('<', i);
if (lt === -1) return false;
- css += t.slice(i, lt);
- if (t.startsWith('', lt + 4) + 3;
- if (i === 2) return false;
- } else if (t.startsWith('', lt + 9);
+ css += text.slice(i, lt);
+ const [start, close] = text.startsWith(''] : text.startsWith(''] : [-1, ''];
+ if (start !== -1) {
+ const end = text.indexOf(close, start);
if (end === -1) return false;
- css += t.slice(lt + 9, end);
+ css += ' ' + text.slice(start, end) + ' ';
i = end + 3;
- } else if (SVG_STYLE_CLOSE.test(t.slice(lt, lt + 256))) {
+ } else if (SVG_STYLE_CLOSE.test(text.slice(lt, lt + 256))) {
at = lt + 1;
break;
} else return false;
}
- if (!cssDrawing(css)) return false;
+ if (css.includes('&') || !cssDrawing(css)) return false;
}
for (let at = t.indexOf('<'); at !== -1; at = t.indexOf('<', at + 1)) {
if (SVG_MARKUP.test(t.slice(at, at + 512))) continue;
diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts
index 1ae42964e..ee49107c4 100644
--- a/test/review-packets-removed.test.ts
+++ b/test/review-packets-removed.test.ts
@@ -61,6 +61,7 @@ const NO_LOSS_REPLAYS = {
'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged",
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged",
+ 'GST-940': "GST-940 verdict no-loss check: svgDrawing walks style bodies over the raw text, refuses any & in them and reads comments as CSS; on 62,805 unique SVG files (21,658 with a style element) it gives the same verdict as main at 4bbad2422 on every file, and the planner over the last 100 first-parent main commits to 4bbad2422 changes no commit's depth, class or risk classes; the only verdicts it changes are the five fail-open cases in test/review-planner-misreads.test.ts",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
const REVIEWER_BRIEFS: Record = {
@@ -82,7 +83,7 @@ const REVIEWER_BRIEFS: Record = {
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
- 'lib/review-depth.ts': { sha: '166dd7b1148a272b345dd9d2ceccd14348cc59c52ef9c88109436f6f61cf6b69', replay: 'GST-911-redos' },
+ 'lib/review-depth.ts': { sha: 'e3b0be6299d408f881a0679672362463791dd3f4ba3cb325208f9b8eb0058720', replay: 'GST-940' },
'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-911' },
};