mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 08:59:01 +02:00
fix(make-pdf): close offline-gate bypasses via unquoted style attrs, CSS-escape and HTML-entity obfuscation
Three live vectors found by the ship review army, all red-first tested: unquoted style attributes skipped the remote-url neutralizer entirely; CSS ident/string escapes (@\69mport, url(\68ttps://…)) defeated the literal-match patterns Chromium happily decodes; and HTML entities in style attribute values (https) decoded to fetchable schemes before CSS parsing. Style-attr values are now entity-decoded in one browser- faithful pass, escape-bearing at-rules and function tokens are dropped fail-closed, and output is re-encoded double-quoted. 21 new test rows.
This commit is contained in:
+78
-9
@@ -251,19 +251,88 @@ export function sanitizeUntrustedHtml(html: string): string {
|
||||
// at print time; the image inliner covers <img src> only, and must keep
|
||||
// seeing remote <img src> so its blocked-remote placeholder still fires) ──
|
||||
|
||||
// Remote url(...) in CSS → url(#). Scoped to <style> blocks and style
|
||||
// Untrusted CSS neutralization. Scoped to <style> blocks and style
|
||||
// attributes below so prose/code samples that mention URLs stay untouched.
|
||||
const neutralizeRemoteCssUrls = (css: string): string =>
|
||||
css.replace(/url\(\s*(?:"|�?39;|'|["'])?\s*(?:https?:)?\/\/[^)]*\)/gi, "url(#)");
|
||||
//
|
||||
// Chromium decodes CSS ident/string escapes (\69 → i, \68 → h) before
|
||||
// fetching, so literal patterns alone are bypassable: @\69mport dodges
|
||||
// /@import\b/, url("\68ttps://…") dodges the https?://-shaped remote-url
|
||||
// pattern, and u\72l(…) dodges the url( prefix itself. Untrusted styling
|
||||
// has no legitimate need for escaped url schemes or at-rule names, so any
|
||||
// construct carrying a backslash escape is dropped/defanged (fail closed).
|
||||
// In style ATTRIBUTES the HTML parser also entity-decodes before the CSS
|
||||
// parser runs, so \ / \ / \ spellings of the backslash count
|
||||
// as escapes too. (<style> content is raw text — no entity layer there.)
|
||||
const CSS_ESCAPE_MARKER = /\\|�*92(?![0-9])|�*5c(?![0-9a-f])|\/i;
|
||||
const neutralizeUntrustedCss = (css: string): string => {
|
||||
// (a) At-rules whose keyword carries a backslash escape (@\69mport …):
|
||||
// drop the whole statement through `;`, `{`, or end-of-value.
|
||||
let out = css.replace(
|
||||
/@[-\w\\&#;]*?(?:\\|�*92(?![0-9]);?|�*5c(?![0-9a-f]);?|\)[-\w\\&#;]*[^;{}]*(?:;|\{|$)/gi,
|
||||
"");
|
||||
// (b) Literal @import is always a fetch (relative ones can't resolve
|
||||
// under load-html either) — drop outright.
|
||||
out = out.replace(/@import\b[^;]*(;|$)/gi, "");
|
||||
// (c) Any function-like token whose name or arguments carry a backslash
|
||||
// escape → url(#). Covers escaped schemes (url("\68ttps://…")) and
|
||||
// escaped function names (u\72l(…)) in one fail-closed pass. The
|
||||
// end-of-value alternative closes the unterminated-url() dodge:
|
||||
// Chromium's CSS parser closes an open function token at EOF.
|
||||
out = out.replace(/[-\w\\&#;][-\w \t\\&#;]*\(\s*[^)]*(?:\)|$)/g, (m) =>
|
||||
CSS_ESCAPE_MARKER.test(m) ? "url(#)" : m);
|
||||
// (d) Remote url(...) → url(#).
|
||||
out = out.replace(
|
||||
/url\(\s*(?:"|�?39;|'|["'])?\s*(?:https?:)?\/\/[^)]*(?:\)|$)/gi,
|
||||
"url(#)");
|
||||
return out;
|
||||
};
|
||||
|
||||
// Raw-HTML <style> blocks: drop @import outright (any @import is a fetch;
|
||||
// relative ones can't resolve under load-html either), neutralize remote url().
|
||||
// Raw-HTML <style> blocks. Element content is RAW TEXT — the HTML parser
|
||||
// never entity-decodes it — so unlike style attributes below, no entity
|
||||
// decode step is needed (or correct) here.
|
||||
s = s.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open, css, close) =>
|
||||
open + neutralizeRemoteCssUrls(css.replace(/@import\b[^;]*(;|$)/gi, "")) + close);
|
||||
open + neutralizeUntrustedCss(css) + close);
|
||||
|
||||
// Inline style="background:url(https://…)" attributes.
|
||||
s = s.replace(/(\s+style\s*=\s*)("[^"]*"|'[^']*')/gi,
|
||||
(_m, pre, val) => pre + neutralizeRemoteCssUrls(val));
|
||||
// Style ATTRIBUTE values are entity-decoded by the HTML parser before the
|
||||
// CSS parser ever runs, so https://… reaches Chromium as https://… and
|
||||
// // as // — dodging every literal pattern above. Decode the value
|
||||
// the way the parser will (numeric dec/hex refs with the spec's optional
|
||||
// semicolon; the syntax-significant named refs; the legacy semicolonless
|
||||
// four), in ONE left-to-right pass so the sanitizer performs exactly the
|
||||
// browser's single decode round — decoding recursively would turn a
|
||||
// double-encoded &#104; into a live scheme the browser never sees.
|
||||
const NAMED_REFS: Record<string, string> = {
|
||||
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'",
|
||||
sol: "/", bsol: "\\", colon: ":", semi: ";", num: "#",
|
||||
lpar: "(", rpar: ")", commat: "@", grave: "`",
|
||||
Tab: "\t", NewLine: "\n",
|
||||
};
|
||||
const refCodePoint = (n: number): string =>
|
||||
(!Number.isFinite(n) || n <= 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff))
|
||||
? "�" : String.fromCodePoint(n);
|
||||
const decodeStyleAttrEntities = (v: string): string => v.replace(
|
||||
/&(?:#[xX]([0-9a-fA-F]+);?|#(\d+);?|([a-zA-Z]+);|(amp|lt|gt|quot)(?![a-zA-Z0-9=;]))/g,
|
||||
(m, hex, dec, named, legacy) => {
|
||||
if (hex !== undefined) return refCodePoint(parseInt(hex, 16));
|
||||
if (dec !== undefined) return refCodePoint(parseInt(dec, 10));
|
||||
if (named !== undefined) return NAMED_REFS[named] ?? m;
|
||||
return NAMED_REFS[legacy];
|
||||
});
|
||||
|
||||
// Inline style attributes — quoted AND unquoted. HTML spec: an unquoted
|
||||
// attribute value runs until whitespace or `>`, so
|
||||
// <div style=background:url(https://…)> is live markup Chromium honors;
|
||||
// a quoted-only pattern misses it. The value is unquoted, entity-decoded
|
||||
// (see above), neutralized in decoded form, then RE-ENCODED and emitted
|
||||
// double-quoted — never emit decoded text raw (a decoded `"` would break
|
||||
// out of the attribute) and the re-encode also keeps once-decoded text like
|
||||
// h inert instead of granting it a second decode round.
|
||||
s = s.replace(/(\s+style\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'>][^\s>]*))/gi,
|
||||
(_m, pre, dq, sq, uq) => {
|
||||
const raw = dq ?? sq ?? uq;
|
||||
const cleaned = neutralizeUntrustedCss(decodeStyleAttrEntities(raw));
|
||||
return `${pre}"${escapeHtml(cleaned)}"`;
|
||||
});
|
||||
|
||||
// srcset with a remote candidate: Chromium prefers srcset over the inlined
|
||||
// src, so a remote candidate fetches at print time. Strip the attribute;
|
||||
|
||||
@@ -47,6 +47,90 @@ describe("sanitizeUntrustedHtml (offline fetch vectors)", () => {
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
// ── Bypass regressions: unquoted style attributes ──
|
||||
// HTML spec: an unquoted attribute value runs until whitespace or `>`, so
|
||||
// <div style=background:url(https://…)> is live markup Chromium honors.
|
||||
// The original neutralizer only rewrote quoted values.
|
||||
|
||||
test("neutralizes remote url() in UNQUOTED style attributes", () => {
|
||||
const out = sanitizeUntrustedHtml(`<div style=background:url(https://evil.example/px.gif)>x</div>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
expect(out).toContain("url(#)");
|
||||
});
|
||||
|
||||
test("keeps local url() in unquoted style attributes functional", () => {
|
||||
const out = sanitizeUntrustedHtml(`<div style=background:url(local.png)>x</div>`);
|
||||
expect(out).toContain("url(local.png)");
|
||||
});
|
||||
|
||||
// ── Bypass regressions: CSS-escape obfuscation ──
|
||||
// Chromium decodes CSS ident/string escapes before fetching, so \69 → i and
|
||||
// \68 → h defeat literal-pattern matching. Untrusted styling has no
|
||||
// legitimate need for escaped url schemes or at-rule names — fail closed.
|
||||
|
||||
test("drops CSS-escaped @import (@\\69mport url(...)) in <style> blocks", () => {
|
||||
const out = sanitizeUntrustedHtml(`<style>@\\69mport url("https://evil.example/a.css");</style>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
expect(out).not.toMatch(/@\\/); // no escaped at-rule survives for Chromium to decode
|
||||
});
|
||||
|
||||
test("drops CSS-escaped string-form @import (@\\69mport \"https://…\")", () => {
|
||||
const out = sanitizeUntrustedHtml(`<style>@\\69mport "https://evil.example/a.css";</style>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
expect(out).not.toMatch(/@\\/);
|
||||
});
|
||||
|
||||
test("neutralizes CSS-escaped scheme inside url() (\\68ttps://…)", () => {
|
||||
const out = sanitizeUntrustedHtml(`<style>body{background:url("\\68ttps://evil.example/px.gif")}</style>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("neutralizes CSS-escaped function names (u\\72l(https://…))", () => {
|
||||
const out = sanitizeUntrustedHtml(`<style>body{background:u\\72l(https://evil.example/px.gif)}</style>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("neutralizes HTML-entity-encoded backslash escapes in style attributes", () => {
|
||||
// Attribute values are entity-decoded by the HTML parser before the CSS
|
||||
// parser runs, so \68ttps reaches Chromium as \68ttps → https.
|
||||
const out = sanitizeUntrustedHtml(`<div style="background:url('\68ttps://evil.example/px.gif')">x</div>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
// ── Bypass regressions: non-backslash entity obfuscation in style attrs ──
|
||||
// The same attribute entity layer can hide ANY character of a fetch vector,
|
||||
// not just backslashes: h → h, / → /. <style> BLOCKS don't need
|
||||
// this handling — element content is raw text, never attribute-decoded.
|
||||
|
||||
test("neutralizes numeric-entity-obfuscated scheme in style attributes (https)", () => {
|
||||
const out = sanitizeUntrustedHtml(`<div style="background:url(https://evil.example/px.gif)">x</div>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("neutralizes entity-obfuscated slashes in style attributes (// for //)", () => {
|
||||
const out = sanitizeUntrustedHtml(`<div style="background:url(https://evil.example/px.gif)">x</div>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("neutralizes entity-obfuscated url( name in style attributes (url)", () => {
|
||||
const out = sanitizeUntrustedHtml(`<div style="background:url(https://evil.example/px.gif)">x</div>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("entity-decoded local styles stay functional and safely re-encoded", () => {
|
||||
const out = sanitizeUntrustedHtml(`<div style="content:"hi";background:url(local.png)">x</div>`);
|
||||
expect(out).toContain("url(local.png)");
|
||||
expect(out).toContain(""hi"");
|
||||
});
|
||||
|
||||
test("double-encoded entities are not double-decoded into a live scheme", () => {
|
||||
// Browser decodes &#104; exactly once → literal https://… text,
|
||||
// which is not a scheme. The sanitizer must mirror that single decode:
|
||||
// decoding twice would CREATE url(https://…) where the browser sees none.
|
||||
const out = sanitizeUntrustedHtml(`<div style="background:url(&#104;ttps://evil.example/px.gif)">x</div>`);
|
||||
expect(out).not.toMatch(/url\(\s*https:/);
|
||||
});
|
||||
|
||||
test("strips srcset with a remote candidate, leaves src for the inliner", () => {
|
||||
const input = `<img src="local.png" srcset="local.png 1x, https://evil.example/x.png 2x">`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
@@ -93,4 +177,15 @@ describe("sanitizeUntrustedHtml (offline fetch vectors)", () => {
|
||||
const { bodyHtml } = render({ markdown: md });
|
||||
expect(bodyHtml).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("end-to-end: unquoted and CSS-escaped vectors don't survive render()", () => {
|
||||
const md = [
|
||||
"# Doc",
|
||||
`<style>@\\69mport "https://evil.example/b.css"; body{background:url("\\68ttps://evil.example/px.gif")}</style>`,
|
||||
`<div style=background:url(https://evil.example/px.gif)>hi</div>`,
|
||||
].join("\n\n");
|
||||
const { bodyHtml } = render({ markdown: md });
|
||||
expect(bodyHtml).not.toContain("evil.example");
|
||||
expect(bodyHtml).not.toMatch(/@\\/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user