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:
Garry Tan
2026-08-14 12:39:21 -07:00
co-authored by Sina Matian Claude Fable 5
parent a83292b1ed
commit 93d92c4585
2 changed files with 128 additions and 0 deletions
+32
View File
@@ -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*(?:&quot;|&#0?39;|&#x27;|["'])?\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;
}