mirror of https://github.com/garrytan/gstack.git
fix(make-pdf): close the offline-gate bypass via raw-HTML fetch vectors
With --allow-network off, the sanitizer stripped script/iframe/link but let Chromium fetch remote resources at print time through four raw-HTML vectors: <style> @import (any form), remote url() in <style> blocks and inline style attributes (incl. protocol-relative //), srcset with a remote candidate (Chromium prefers srcset over the inlined src), and remote src/poster on video/audio/source/track. All neutralized at the sanitizer; remote <img src> is deliberately left for the image inliner so its blocked-remote placeholder still fires, and url() mentions in prose/code spans stay untouched. Fork's test suite ported verbatim (12 cases incl. the end-to-end render assertion), verified RED against the old sanitizer. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a83292b1ed
commit
93d92c4585
|
|
@ -247,6 +247,38 @@ export function sanitizeUntrustedHtml(html: string): string {
|
|||
// style="url(javascript:..)" — strip javascript: inside style attrs.
|
||||
s = s.replace(/url\(\s*javascript:[^)]*\)/gi, "url(#)");
|
||||
|
||||
// ── Offline-posture fetch vectors (no --allow-network must mean no network
|
||||
// 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
|
||||
// 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(#)");
|
||||
|
||||
// Raw-HTML <style> blocks: drop @import outright (any @import is a fetch;
|
||||
// relative ones can't resolve under load-html either), neutralize remote url().
|
||||
s = s.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open, css, close) =>
|
||||
open + neutralizeRemoteCssUrls(css.replace(/@import\b[^;]*(;|$)/gi, "")) + close);
|
||||
|
||||
// Inline style="background:url(https://…)" attributes.
|
||||
s = s.replace(/(\s+style\s*=\s*)("[^"]*"|'[^']*')/gi,
|
||||
(_m, pre, val) => pre + neutralizeRemoteCssUrls(val));
|
||||
|
||||
// srcset with a remote candidate: Chromium prefers srcset over the inlined
|
||||
// src, so a remote candidate fetches at print time. Strip the attribute;
|
||||
// local/data: srcset values are left alone.
|
||||
const remoteSrcsetCandidate = /(?:^|[,\s])\s*(?:https?:)?\/\//i;
|
||||
s = s.replace(/\s+srcset\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, (m, val) =>
|
||||
remoteSrcsetCandidate.test(String(val).replace(/^["']|["']$/g, "")) ? "" : m);
|
||||
|
||||
// Remote src/poster on media elements (<video poster>, <source src>, …).
|
||||
s = s.replace(/<(?:video|audio|source|track)\b[^>]*>/gi, (tag) =>
|
||||
tag.replace(
|
||||
/(\s(?:src|poster)\s*=\s*)(?:"(?:https?:)?\/\/[^"]*"|'(?:https?:)?\/\/[^']*'|(?:https?:)?\/\/[^\s>]+)/gi,
|
||||
'$1"#"',
|
||||
));
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Offline-posture sanitizer tests — raw-HTML fetch vectors beyond <img src>
|
||||
* (which the image inliner owns). No Playwright, no PDF generation.
|
||||
*
|
||||
* Regression for: <style>@import, inline style="…url(https://…)…", and
|
||||
* <img srcset> surviving sanitizeUntrustedHtml, letting Chromium fetch
|
||||
* remote resources at print time with no --allow-network.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { render, sanitizeUntrustedHtml } from "../src/render";
|
||||
|
||||
describe("sanitizeUntrustedHtml (offline fetch vectors)", () => {
|
||||
test("strips @import url(...) from raw <style> blocks, keeps legit rules", () => {
|
||||
const input = `<style>@import url("https://evil.example/track.css");\nh1 { color: red; }</style>`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("@import");
|
||||
expect(out).not.toContain("evil.example");
|
||||
expect(out).toContain("color: red");
|
||||
});
|
||||
|
||||
test("strips string-form @import (no url())", () => {
|
||||
const out = sanitizeUntrustedHtml(`<style>@import "https://evil.example/a.css";</style>`);
|
||||
expect(out).not.toContain("@import");
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("neutralizes remote url() inside <style> blocks", () => {
|
||||
const input = `<style>body { background: url(https://evil.example/px.gif); color: blue; }</style>`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("evil.example");
|
||||
expect(out).toContain("url(#)");
|
||||
expect(out).toContain("color: blue");
|
||||
});
|
||||
|
||||
test("neutralizes remote url() in inline style attributes", () => {
|
||||
const input = `<div style="background:url(https://evil.example/px.gif);padding:4px">x</div>`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("evil.example");
|
||||
expect(out).toContain("url(#)");
|
||||
expect(out).toContain("padding:4px");
|
||||
});
|
||||
|
||||
test("neutralizes protocol-relative url(//…) in style attributes", () => {
|
||||
const out = sanitizeUntrustedHtml(`<div style="background:url(//evil.example/px.gif)">x</div>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
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);
|
||||
expect(out).not.toContain("srcset");
|
||||
expect(out).not.toContain("evil.example");
|
||||
expect(out).toContain(`src="local.png"`);
|
||||
});
|
||||
|
||||
test("strips unquoted remote srcset", () => {
|
||||
const out = sanitizeUntrustedHtml(`<img src="a.png" srcset=https://evil.example/x.png>`);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("keeps local-only srcset (no network vector)", () => {
|
||||
const input = `<img src="a.png" srcset="a.png 1x, a@2x.png 2x">`;
|
||||
expect(sanitizeUntrustedHtml(input)).toContain(`srcset="a.png 1x, a@2x.png 2x"`);
|
||||
});
|
||||
|
||||
test("neutralizes remote <video poster> and <source src>", () => {
|
||||
const input = `<video poster="https://evil.example/p.jpg"><source src="https://evil.example/v.mp4"></video>`;
|
||||
const out = sanitizeUntrustedHtml(input);
|
||||
expect(out).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
test("leaves remote <img src> alone — the image inliner owns its blocked-remote placeholder", () => {
|
||||
const input = `<img src="https://evil.example/px.gif">`;
|
||||
expect(sanitizeUntrustedHtml(input)).toContain(`src="https://evil.example/px.gif"`);
|
||||
});
|
||||
|
||||
test("does NOT rewrite url(https://…) mentioned in code/prose text", () => {
|
||||
// marked entity-encodes quotes in code spans, so this is plain text
|
||||
// outside any <style> block or style attribute — must stay intact.
|
||||
const input = `<code>background: url(https://example.com/a.png)</code>`;
|
||||
expect(sanitizeUntrustedHtml(input)).toContain("url(https://example.com/a.png)");
|
||||
});
|
||||
|
||||
test("end-to-end: rendered document carries no remote fetch vector from raw HTML", () => {
|
||||
const md = [
|
||||
"# Doc",
|
||||
`<style>@import url("https://evil.example/t.css"); h1{color:red}</style>`,
|
||||
`<div style="background:url('https://evil.example/px.gif')">hi</div>`,
|
||||
`<img src="a.png" srcset="https://evil.example/a.png 2x">`,
|
||||
].join("\n\n");
|
||||
const { bodyHtml } = render({ markdown: md });
|
||||
expect(bodyHtml).not.toContain("evil.example");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue