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:
Garry Tan 2026-08-14 17:11:48 -07:00
parent 81a2d48592
commit 3f53b9e173
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 323 additions and 9 deletions

View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Migration: v1.65.0.0 — repair Chrome-for-Testing bundles poisoned by the
# old in-place rebrand (#2242).
#
# Why a migration: pre-v1.64 launchHeaded() rewrote the Chromium .app's
# Info.plist ("Google Chrome for Testing" → "GStack Browser") and overwrote
# its Resources/*.icns — inside the SHARED Playwright cache. That broke the
# codesign seal (GPU process exit_code=5; headed mode dead on macOS 26) and
# poisoned the cache for the user's OTHER Playwright projects too. Deleting
# the rebrand code fixes fresh installs only; every existing macOS install
# still has the mutated bundle on disk. This migration removes poisoned
# bundles and re-fetches a clean one so the upgrade doesn't leave the user
# with zero working browser (the browse launch path also self-heals, as the
# belt to this suspenders, for installs that never run migrations).
#
# Removal scope: `playwright install chromium` treats the revision dir's
# INSTALLATION_COMPLETE marker as "is already downloaded" — removing only
# the .app strands the user with a marker, no browser, and a re-fetch that
# no-ops. So when the poisoned .app sits in the standard cache layout
# (chromium-<rev>/chrome-mac/<name>.app) the WHOLE revision dir goes;
# otherwise the .app plus its sibling INSTALLATION_COMPLETE /
# DEPENDENCIES_VALIDATED markers go. A revision dir already stranded in
# exactly that state (markers present, .app missing) is detected and
# removed too, so the re-fetch actually downloads.
#
# Affected: macOS installs that ever ran headed mode before v1.64.
#
# Idempotent: detection is content-based (plist contains "GStack Browser");
# a clean cache is a no-op, and the .done touchfile gates re-runs. After a
# removal, .done is only written once the end state is VERIFIED (a real
# Chromium executable exists in the cache) — a removal followed by a failed
# re-fetch (e.g. offline) leaves the migration pending, with a needs-refetch
# sentinel so the next run retries the download.
set -u
GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}"
MIGRATION_DIR="${GSTACK_HOME}/.migrations"
DONE="${MIGRATION_DIR}/v1.65.0.0.done"
# Written when a removal happened but the verified end state (a Chromium
# executable in the cache) wasn't reached — e.g. the re-fetch failed
# offline. Its presence re-triggers the re-fetch on the next run even when
# the scans below find nothing left to remove.
NEEDS_REFETCH="${MIGRATION_DIR}/v1.65.0.0.needs-refetch"
mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true
[ -f "${DONE}" ] && exit 0
# macOS only: the mutation targeted .app bundle plists.
if [ "$(uname -s 2>/dev/null)" != "Darwin" ]; then
touch "${DONE}"
exit 0
fi
PW_CACHE="${PLAYWRIGHT_BROWSERS_PATH:-${HOME}/Library/Caches/ms-playwright}"
REMOVED=0
[ -f "${NEEDS_REFETCH}" ] && REMOVED=1
is_revision_dir() {
# Standard Playwright cache revision dir name: chromium-<digits>.
printf '%s' "$(basename "$1")" | grep -Eq '^chromium-[0-9]+$'
}
if [ -d "${PW_CACHE}" ]; then
# 1. Content-based poison scan: every Chrome-for-Testing bundle in the
# cache (one per pinned chromium build) whose plist carries the rebrand.
while IFS= read -r plist; do
if grep -q "GStack Browser" "${plist}" 2>/dev/null; then
app_dir="$(dirname "$(dirname "${plist}")")"
case "${app_dir}" in
"${PW_CACHE}"/*.app|"${PW_CACHE}"/*/*.app|"${PW_CACHE}"/*/*/*.app)
rev_dir="$(dirname "$(dirname "${app_dir}")")"
if is_revision_dir "${rev_dir}"; then
# Remove the WHOLE revision dir: Playwright's
# INSTALLATION_COMPLETE marker lives beside chrome-mac/, and
# `playwright install chromium` treats its presence as "already
# downloaded" — removing only the .app would make the re-fetch
# below a no-op and leave the user with NO browser.
echo " [v1.65.0.0] removing rebrand-poisoned revision dir (incl. install markers): ${rev_dir}" >&2
rm -rf "${rev_dir}"
else
echo " [v1.65.0.0] removing rebrand-poisoned bundle: ${app_dir}" >&2
rm -rf "${app_dir}"
rm -f "$(dirname "${app_dir}")/INSTALLATION_COMPLETE" \
"$(dirname "${app_dir}")/DEPENDENCIES_VALIDATED" 2>/dev/null || true
fi
REMOVED=1
;;
*)
echo " [v1.65.0.0] WARNING: poisoned plist outside the Playwright cache shape, skipping: ${plist}" >&2
;;
esac
fi
done < <(find "${PW_CACHE}" -maxdepth 5 -name "Info.plist" -path "*.app/Contents/Info.plist" 2>/dev/null)
# 2. Stranded-state scan: an earlier version of this migration removed
# only the poisoned .app, leaving the revision dir with its
# INSTALLATION_COMPLETE marker — the exact state that makes
# `playwright install chromium` no-op while the user has NO browser.
# A chromium revision dir without any .app inside is that strand;
# remove it whole so the re-fetch actually downloads.
for rev_dir in "${PW_CACHE}"/chromium-*; do
[ -d "${rev_dir}" ] || continue
is_revision_dir "${rev_dir}" || continue
if [ -z "$(find "${rev_dir}" -maxdepth 2 -name '*.app' -print 2>/dev/null | head -1)" ]; then
echo " [v1.65.0.0] removing stranded revision dir (install markers without a browser): ${rev_dir}" >&2
rm -rf "${rev_dir}"
REMOVED=1
fi
done
fi
if [ "${REMOVED}" = "1" ]; then
# Re-fetch immediately: migrations run AFTER ./setup, so without this the
# user finishes the upgrade with no working browser at all (headless AND
# headed use the same bundle). Run from the gstack install root so bunx
# resolves the repo-pinned playwright version — an arbitrary migration-
# runner cwd could resolve a different playwright and populate a revision
# the pinned one never launches (same subshell-cd pattern as ./setup's
# Chromium install block).
SCRIPT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
echo " [v1.65.0.0] re-fetching a clean Chromium (bunx playwright install chromium)..." >&2
if command -v bunx >/dev/null 2>&1 && (cd "${SCRIPT_DIR}" && bunx playwright install chromium >&2); then
echo " [v1.65.0.0] playwright install finished." >&2
else
echo " [v1.65.0.0] WARNING: automatic re-fetch failed." >&2
fi
# Gate .done on the VERIFIED end state, not the installer's exit code:
# `playwright install` exits 0 even when it skips the download, and a
# successful removal followed by a failed/offline fetch must not be
# recorded as done — that would strand the user with no browser and a
# success message.
CHROME_EXE="$(find "${PW_CACHE}" -maxdepth 6 -type f -perm -u+x -path "*/chromium-*/*.app/Contents/MacOS/*" 2>/dev/null | head -1)"
if [ -n "${CHROME_EXE}" ]; then
echo " [v1.65.0.0] verified working Chromium at: ${CHROME_EXE}" >&2
rm -f "${NEEDS_REFETCH}" 2>/dev/null || true
else
touch "${NEEDS_REFETCH}" 2>/dev/null || true
echo " [v1.65.0.0] WARNING: no Chromium executable present after removing the poisoned bundle." >&2
echo " [v1.65.0.0] Headless AND headed browsing are unavailable until it is re-fetched. Run:" >&2
echo " [v1.65.0.0] cd ${SCRIPT_DIR} && bunx playwright install chromium" >&2
echo " [v1.65.0.0] Leaving this migration pending — it retries on the next run." >&2
exit 0
fi
else
echo " [v1.65.0.0] no rebrand-poisoned bundles found — no-op." >&2
fi
touch "${DONE}"
exit 0

View File

@ -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*(?:&quot;|&#0?39;|&#x27;|["'])?\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 &#92; / &#x5c; / &bsol; spellings of the backslash count
// as escapes too. (<style> content is raw text — no entity layer there.)
const CSS_ESCAPE_MARKER = /\\|&#0*92(?![0-9])|&#x0*5c(?![0-9a-f])|&bsol;/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\\&#;]*?(?:\\|&#0*92(?![0-9]);?|&#x0*5c(?![0-9a-f]);?|&bsol;)[-\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*(?:&quot;|&#0?39;|&#x27;|["'])?\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 &#104;ttps://… reaches Chromium as https://… and
// &#47;&#47; 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 &amp;#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))
? "<22>" : 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
// &#104; 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;

View File

@ -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 &#92;68ttps reaches Chromium as \68ttps → https.
const out = sanitizeUntrustedHtml(`<div style="background:url('&#92;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: &#104; → h, &#47; → /. <style> BLOCKS don't need
// this handling — element content is raw text, never attribute-decoded.
test("neutralizes numeric-entity-obfuscated scheme in style attributes (&#104;ttps)", () => {
const out = sanitizeUntrustedHtml(`<div style="background:url(&#104;ttps://evil.example/px.gif)">x</div>`);
expect(out).not.toContain("evil.example");
});
test("neutralizes entity-obfuscated slashes in style attributes (&#47;&#47; for //)", () => {
const out = sanitizeUntrustedHtml(`<div style="background:url(https:&#47;&#47;evil.example/px.gif)">x</div>`);
expect(out).not.toContain("evil.example");
});
test("neutralizes entity-obfuscated url( name in style attributes (&#117;rl)", () => {
const out = sanitizeUntrustedHtml(`<div style="background:&#117;rl(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:&quot;hi&quot;;background:url(local.png)">x</div>`);
expect(out).toContain("url(local.png)");
expect(out).toContain("&quot;hi&quot;");
});
test("double-encoded entities are not double-decoded into a live scheme", () => {
// Browser decodes &amp;#104; exactly once → literal &#104;ttps://… 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(&amp;#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(/@\\/);
});
});