From c2c1f8153027fd946fc345c6a1b6dda1cfa1218c Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:01:15 +0300 Subject: [PATCH 1/3] Fix script tag breakout in generated HTML maps ### Summary of Changes Resolves **F19 (High)** and **F20 (High)** from the Uphold Workspace Security Audit: - **HTML Breakout / Stored XSS Prevention (F19 & F20):** - Updated `build_perf_map.mjs` and `build_lcp_map.mjs` to escape `` closing tags before injecting JSON payloads into template script elements. - Neutralizes DOM-based script execution vectors originating from untrusted target repository filenames and traced Chrome resource URLs. - Aligns HTML map generation with the defensive escaping already present in `build_run_report.mjs`. ### Verification - `npm test` passes cleanly (6/6 self-tests, 6/6 invariant validations). - End-to-end artifact generation confirms inputs containing `` are serialized as `<\/script>` within HTML `` would otherwise + // close the script element and execute attacker-chosen script when the report is opened. This + // report is published to GitHub Pages, so escape before injecting. + const html = template + .split(LIB_MARKER).join(lib) + .split(DATA_MARKER).join(dataJson.replace(/<\/(script)/gi, '<\\/$1')); + + fs.writeFileSync(outFile, html); + const outAbs = path.resolve(outFile); + const kb = (fs.statSync(outAbs).size / 1024).toFixed(0); + + console.log(`\nbrowsergnome lcp-map`); + console.log(` wrote ${outAbs} (${kb} KB, standalone)`); + console.log(` ${payload.meta.url || '(no url)'} · LCP ${payload.meta.lcpMs != null ? Math.round(payload.meta.lcpMs) + 'ms' : '?'}`); + console.log(` ${payload.meta.counts.network} network · ${payload.meta.counts.chunk} chunk · ${payload.meta.counts.module} module` + + (payload.meta.degraded ? ' (degraded: no bundle stats, chunk-only)' : '')); + if (payload.meta.transport?.legacyHttp) { + const t = payload.meta.transport; + console.log(` ⚠ transport: HTTP/1.1 (${t.http1Count}/${t.firstPartyTotal} first-party requests) — results may not represent production (HTTP/2 CDN). See banner.`); + } + if (payload.meta.throttled) { + const t = payload.meta.throttled; + console.log(` throttled reference (${t.label}): FCP ${t.fcpMs != null ? Math.round(t.fcpMs) + 'ms' : '?'} ·` + + ` LCP ${t.lcpMs != null ? Math.round(t.lcpMs) + 'ms' : '?'} · TBT ${t.tbtMs != null ? Math.round(t.tbtMs) + 'ms' : '?'}`); + } + if (payload.meta.crux) { + const m = payload.meta.crux.metrics; + const src = payload.meta.crux.source === 'psi' ? 'via PageSpeed Insights, keyless' : 'CrUX API'; + console.log(` field data (${src}, real users, origin): LCP ${m.lcp.p75 != null ? Math.round(m.lcp.p75) + 'ms' : '?'} ·` + + ` CLS ${m.cls.p75 != null ? m.cls.p75.toFixed(2) : '?'} · INP ${m.inp.p75 != null ? Math.round(m.inp.p75) + 'ms' : '?'}`); + } else if (prodUrl) { + console.log(` no CrUX field data for ${prodUrl} (low-traffic origin, or offline) — field-data tier omitted`); + } + console.log(` open with: open ${outAbs}\n`); + + if (open) { + const opener = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'cmd' : 'xdg-open'; + const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs]; + execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); }); + } +} + +// Guarded like lcp_attribution.mjs/trace_metrics.mjs's identical checks — lets +// resolveProdUrl/getFieldData be imported by another script (e.g. a test) +// without also triggering a CLI run as a side effect. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { console.error(err); process.exit(1); }); +} diff --git a/skills/browsergnome/scripts/build_perf_map.mjs b/skills/browsergnome/scripts/build_perf_map.mjs old mode 100644 new mode 100755 index 86204a8..2d21d42 --- a/skills/browsergnome/scripts/build_perf_map.mjs +++ b/skills/browsergnome/scripts/build_perf_map.mjs @@ -1,98 +1,103 @@ -#!/usr/bin/env node -/** - * build_perf_map.mjs — merge graph.json into the HTML template to produce a - * single, fully standalone perf-map.html. - * - * "Standalone" is a hard requirement: the vendored 3d-force-graph build (which - * bundles three.js) and the graph payload are both INLINED into the output, so - * perf-map.html renders from file:// with the network disabled and no sibling - * files. That's what makes it safe to hand to anyone / open on a plane. - * - * Usage: - * node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open] - * - * --renders overlays a Page Tools render profile (`[{file, renders, selfMs}]`, written by the agent - * from a react_* profiling pass — see references/page-tools.md) onto matching nodes before injection. - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; -import { fileURLToPath } from 'node:url'; -import { execFile } from 'node:child_process'; -import { mergeRenders } from './page_tools.mjs'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ASSETS = path.resolve(__dirname, '..', 'assets'); -const TEMPLATE = path.join(ASSETS, 'perf-map.template.html'); -const LIB = path.join(ASSETS, '3d-force-graph.min.js'); - -const LIB_MARKER = '/*__FORCE_GRAPH_LIB__*/'; -const DATA_MARKER = '/*__GRAPH_DATA__*/'; - -function main() { - const args = process.argv.slice(2); - const open = args.includes('--open'); - const outIdx = args.indexOf('--out'); - const outFile = outIdx >= 0 ? args[outIdx + 1] : 'perf-map.html'; - const rendersIdx = args.indexOf('--renders'); - const rendersFile = rendersIdx >= 0 ? args[rendersIdx + 1] : null; - const valueIdxs = [outIdx, rendersIdx].filter((i) => i >= 0).map((i) => i + 1); // flag values, not the graph arg - const graphFile = args.find((a, i) => !a.startsWith('--') && !valueIdxs.includes(i)); - - if (!graphFile) { - console.error('usage: node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]'); - process.exit(1); - } - for (const [p, what] of [[graphFile, 'graph'], [TEMPLATE, 'template'], [LIB, 'vendored lib'], ...(rendersFile ? [[rendersFile, 'render profile']] : [])]) { - if (!fs.existsSync(p)) { console.error(`missing ${what}: ${p}`); process.exit(1); } - } - - let graphRaw = fs.readFileSync(graphFile, 'utf8'); - let graph = JSON.parse(graphRaw); // validate it parses before embedding - let unmatched = []; - if (rendersFile) { - ({ graph, unmatched } = mergeRenders(graph, JSON.parse(fs.readFileSync(rendersFile, 'utf8')))); - graphRaw = JSON.stringify(graph); - } - const template = fs.readFileSync(TEMPLATE, 'utf8'); - const lib = fs.readFileSync(LIB, 'utf8'); - - if (!template.includes(LIB_MARKER) || !template.includes(DATA_MARKER)) { - console.error('template is missing an injection marker — did the template change?'); - process.exit(1); - } - - // Literal split/join (not regex) so the 1.3MB minified lib can't be - // misinterpreted as a replacement pattern. - const html = template - .split(LIB_MARKER).join(lib) - .split(DATA_MARKER).join(graphRaw.trim()); - - fs.writeFileSync(outFile, html); - const outAbs = path.resolve(outFile); - const kb = (fs.statSync(outAbs).size / 1024).toFixed(0); - - const s = graph.meta?.stats || {}; - console.log(`\nbrowsergnome perf-map`); - console.log(` wrote ${outAbs} (${kb} KB, standalone)`); - console.log(` ${s.nodes ?? '?'} modules · ${s.hotspots ?? '?'} hotspot(s) · ${s.findings ?? '?'} findings`); - if (rendersFile) { - const overlaid = graph.nodes.filter((n) => 'renders' in n).length; - console.log(` render overlay: ${overlaid} module(s)${unmatched.length ? ` · ${unmatched.length} profiled file(s) not in the graph: ${unmatched.join(', ')}` : ''}`); - } - if (graph.top3?.length) { - console.log(` Top-3:`); - for (const t of graph.top3) console.log(` ${t.rank}. ${t.paste} (debt ${t.debt})`); - } - console.log(` open with: open ${outAbs}\n`); - - if (open) { - const opener = process.platform === 'darwin' ? 'open' - : process.platform === 'win32' ? 'cmd' : 'xdg-open'; - const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs]; - execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); }); - } -} - -main(); +#!/usr/bin/env node +/** + * build_perf_map.mjs — merge graph.json into the HTML template to produce a + * single, fully standalone perf-map.html. + * + * "Standalone" is a hard requirement: the vendored 3d-force-graph build (which + * bundles three.js) and the graph payload are both INLINED into the output, so + * perf-map.html renders from file:// with the network disabled and no sibling + * files. That's what makes it safe to hand to anyone / open on a plane. + * + * Usage: + * node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open] + * + * --renders overlays a Page Tools render profile (`[{file, renders, selfMs}]`, written by the agent + * from a react_* profiling pass — see references/page-tools.md) onto matching nodes before injection. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { mergeRenders } from './page_tools.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ASSETS = path.resolve(__dirname, '..', 'assets'); +const TEMPLATE = path.join(ASSETS, 'perf-map.template.html'); +const LIB = path.join(ASSETS, '3d-force-graph.min.js'); + +const LIB_MARKER = '/*__FORCE_GRAPH_LIB__*/'; +const DATA_MARKER = '/*__GRAPH_DATA__*/'; + +function main() { + const args = process.argv.slice(2); + const open = args.includes('--open'); + const outIdx = args.indexOf('--out'); + const outFile = outIdx >= 0 ? args[outIdx + 1] : 'perf-map.html'; + const rendersIdx = args.indexOf('--renders'); + const rendersFile = rendersIdx >= 0 ? args[rendersIdx + 1] : null; + const valueIdxs = [outIdx, rendersIdx].filter((i) => i >= 0).map((i) => i + 1); // flag values, not the graph arg + const graphFile = args.find((a, i) => !a.startsWith('--') && !valueIdxs.includes(i)); + + if (!graphFile) { + console.error('usage: node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]'); + process.exit(1); + } + for (const [p, what] of [[graphFile, 'graph'], [TEMPLATE, 'template'], [LIB, 'vendored lib'], ...(rendersFile ? [[rendersFile, 'render profile']] : [])]) { + if (!fs.existsSync(p)) { console.error(`missing ${what}: ${p}`); process.exit(1); } + } + + let graphRaw = fs.readFileSync(graphFile, 'utf8'); + let graph = JSON.parse(graphRaw); // validate it parses before embedding + let unmatched = []; + if (rendersFile) { + ({ graph, unmatched } = mergeRenders(graph, JSON.parse(fs.readFileSync(rendersFile, 'utf8')))); + graphRaw = JSON.stringify(graph); + } + const template = fs.readFileSync(TEMPLATE, 'utf8'); + const lib = fs.readFileSync(LIB, 'utf8'); + + if (!template.includes(LIB_MARKER) || !template.includes(DATA_MARKER)) { + console.error('template is missing an injection marker — did the template change?'); + process.exit(1); + } + + // Literal split/join (not regex) so the 1.3MB minified lib can't be + // misinterpreted as a replacement pattern. + // + // The graph payload is embedded inside a ` would close the script element and execute in + // the output page. build_run_report.mjs already escapes this; do the same here. + const html = template + .split(LIB_MARKER).join(lib) + .split(DATA_MARKER).join(graphRaw.trim().replace(/<\/(script)/gi, '<\\/$1')); + + fs.writeFileSync(outFile, html); + const outAbs = path.resolve(outFile); + const kb = (fs.statSync(outAbs).size / 1024).toFixed(0); + + const s = graph.meta?.stats || {}; + console.log(`\nbrowsergnome perf-map`); + console.log(` wrote ${outAbs} (${kb} KB, standalone)`); + console.log(` ${s.nodes ?? '?'} modules · ${s.hotspots ?? '?'} hotspot(s) · ${s.findings ?? '?'} findings`); + if (rendersFile) { + const overlaid = graph.nodes.filter((n) => 'renders' in n).length; + console.log(` render overlay: ${overlaid} module(s)${unmatched.length ? ` · ${unmatched.length} profiled file(s) not in the graph: ${unmatched.join(', ')}` : ''}`); + } + if (graph.top3?.length) { + console.log(` Top-3:`); + for (const t of graph.top3) console.log(` ${t.rank}. ${t.paste} (debt ${t.debt})`); + } + console.log(` open with: open ${outAbs}\n`); + + if (open) { + const opener = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'cmd' : 'xdg-open'; + const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs]; + execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); }); + } +} + +main(); From 774881c737eac3858b66c3b19934ed0d00cd30f2 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:39:19 +0300 Subject: [PATCH 2/3] Convert perf map builder line endings to LF --- .../browsergnome/scripts/build_perf_map.mjs | 206 +++++++++--------- 1 file changed, 103 insertions(+), 103 deletions(-) mode change 100755 => 100644 skills/browsergnome/scripts/build_perf_map.mjs diff --git a/skills/browsergnome/scripts/build_perf_map.mjs b/skills/browsergnome/scripts/build_perf_map.mjs old mode 100755 new mode 100644 index 2d21d42..9954217 --- a/skills/browsergnome/scripts/build_perf_map.mjs +++ b/skills/browsergnome/scripts/build_perf_map.mjs @@ -1,103 +1,103 @@ -#!/usr/bin/env node -/** - * build_perf_map.mjs — merge graph.json into the HTML template to produce a - * single, fully standalone perf-map.html. - * - * "Standalone" is a hard requirement: the vendored 3d-force-graph build (which - * bundles three.js) and the graph payload are both INLINED into the output, so - * perf-map.html renders from file:// with the network disabled and no sibling - * files. That's what makes it safe to hand to anyone / open on a plane. - * - * Usage: - * node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open] - * - * --renders overlays a Page Tools render profile (`[{file, renders, selfMs}]`, written by the agent - * from a react_* profiling pass — see references/page-tools.md) onto matching nodes before injection. - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; -import { fileURLToPath } from 'node:url'; -import { execFile } from 'node:child_process'; -import { mergeRenders } from './page_tools.mjs'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ASSETS = path.resolve(__dirname, '..', 'assets'); -const TEMPLATE = path.join(ASSETS, 'perf-map.template.html'); -const LIB = path.join(ASSETS, '3d-force-graph.min.js'); - -const LIB_MARKER = '/*__FORCE_GRAPH_LIB__*/'; -const DATA_MARKER = '/*__GRAPH_DATA__*/'; - -function main() { - const args = process.argv.slice(2); - const open = args.includes('--open'); - const outIdx = args.indexOf('--out'); - const outFile = outIdx >= 0 ? args[outIdx + 1] : 'perf-map.html'; - const rendersIdx = args.indexOf('--renders'); - const rendersFile = rendersIdx >= 0 ? args[rendersIdx + 1] : null; - const valueIdxs = [outIdx, rendersIdx].filter((i) => i >= 0).map((i) => i + 1); // flag values, not the graph arg - const graphFile = args.find((a, i) => !a.startsWith('--') && !valueIdxs.includes(i)); - - if (!graphFile) { - console.error('usage: node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]'); - process.exit(1); - } - for (const [p, what] of [[graphFile, 'graph'], [TEMPLATE, 'template'], [LIB, 'vendored lib'], ...(rendersFile ? [[rendersFile, 'render profile']] : [])]) { - if (!fs.existsSync(p)) { console.error(`missing ${what}: ${p}`); process.exit(1); } - } - - let graphRaw = fs.readFileSync(graphFile, 'utf8'); - let graph = JSON.parse(graphRaw); // validate it parses before embedding - let unmatched = []; - if (rendersFile) { - ({ graph, unmatched } = mergeRenders(graph, JSON.parse(fs.readFileSync(rendersFile, 'utf8')))); - graphRaw = JSON.stringify(graph); - } - const template = fs.readFileSync(TEMPLATE, 'utf8'); - const lib = fs.readFileSync(LIB, 'utf8'); - - if (!template.includes(LIB_MARKER) || !template.includes(DATA_MARKER)) { - console.error('template is missing an injection marker — did the template change?'); - process.exit(1); - } - - // Literal split/join (not regex) so the 1.3MB minified lib can't be - // misinterpreted as a replacement pattern. - // - // The graph payload is embedded inside a ` would close the script element and execute in - // the output page. build_run_report.mjs already escapes this; do the same here. - const html = template - .split(LIB_MARKER).join(lib) - .split(DATA_MARKER).join(graphRaw.trim().replace(/<\/(script)/gi, '<\\/$1')); - - fs.writeFileSync(outFile, html); - const outAbs = path.resolve(outFile); - const kb = (fs.statSync(outAbs).size / 1024).toFixed(0); - - const s = graph.meta?.stats || {}; - console.log(`\nbrowsergnome perf-map`); - console.log(` wrote ${outAbs} (${kb} KB, standalone)`); - console.log(` ${s.nodes ?? '?'} modules · ${s.hotspots ?? '?'} hotspot(s) · ${s.findings ?? '?'} findings`); - if (rendersFile) { - const overlaid = graph.nodes.filter((n) => 'renders' in n).length; - console.log(` render overlay: ${overlaid} module(s)${unmatched.length ? ` · ${unmatched.length} profiled file(s) not in the graph: ${unmatched.join(', ')}` : ''}`); - } - if (graph.top3?.length) { - console.log(` Top-3:`); - for (const t of graph.top3) console.log(` ${t.rank}. ${t.paste} (debt ${t.debt})`); - } - console.log(` open with: open ${outAbs}\n`); - - if (open) { - const opener = process.platform === 'darwin' ? 'open' - : process.platform === 'win32' ? 'cmd' : 'xdg-open'; - const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs]; - execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); }); - } -} - -main(); +#!/usr/bin/env node +/** + * build_perf_map.mjs — merge graph.json into the HTML template to produce a + * single, fully standalone perf-map.html. + * + * "Standalone" is a hard requirement: the vendored 3d-force-graph build (which + * bundles three.js) and the graph payload are both INLINED into the output, so + * perf-map.html renders from file:// with the network disabled and no sibling + * files. That's what makes it safe to hand to anyone / open on a plane. + * + * Usage: + * node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open] + * + * --renders overlays a Page Tools render profile (`[{file, renders, selfMs}]`, written by the agent + * from a react_* profiling pass — see references/page-tools.md) onto matching nodes before injection. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { mergeRenders } from './page_tools.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ASSETS = path.resolve(__dirname, '..', 'assets'); +const TEMPLATE = path.join(ASSETS, 'perf-map.template.html'); +const LIB = path.join(ASSETS, '3d-force-graph.min.js'); + +const LIB_MARKER = '/*__FORCE_GRAPH_LIB__*/'; +const DATA_MARKER = '/*__GRAPH_DATA__*/'; + +function main() { + const args = process.argv.slice(2); + const open = args.includes('--open'); + const outIdx = args.indexOf('--out'); + const outFile = outIdx >= 0 ? args[outIdx + 1] : 'perf-map.html'; + const rendersIdx = args.indexOf('--renders'); + const rendersFile = rendersIdx >= 0 ? args[rendersIdx + 1] : null; + const valueIdxs = [outIdx, rendersIdx].filter((i) => i >= 0).map((i) => i + 1); // flag values, not the graph arg + const graphFile = args.find((a, i) => !a.startsWith('--') && !valueIdxs.includes(i)); + + if (!graphFile) { + console.error('usage: node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]'); + process.exit(1); + } + for (const [p, what] of [[graphFile, 'graph'], [TEMPLATE, 'template'], [LIB, 'vendored lib'], ...(rendersFile ? [[rendersFile, 'render profile']] : [])]) { + if (!fs.existsSync(p)) { console.error(`missing ${what}: ${p}`); process.exit(1); } + } + + let graphRaw = fs.readFileSync(graphFile, 'utf8'); + let graph = JSON.parse(graphRaw); // validate it parses before embedding + let unmatched = []; + if (rendersFile) { + ({ graph, unmatched } = mergeRenders(graph, JSON.parse(fs.readFileSync(rendersFile, 'utf8')))); + graphRaw = JSON.stringify(graph); + } + const template = fs.readFileSync(TEMPLATE, 'utf8'); + const lib = fs.readFileSync(LIB, 'utf8'); + + if (!template.includes(LIB_MARKER) || !template.includes(DATA_MARKER)) { + console.error('template is missing an injection marker — did the template change?'); + process.exit(1); + } + + // Literal split/join (not regex) so the 1.3MB minified lib can't be + // misinterpreted as a replacement pattern. + // + // The graph payload is embedded inside a ` would close the script element and execute in + // the output page. build_run_report.mjs already escapes this; do the same here. + const html = template + .split(LIB_MARKER).join(lib) + .split(DATA_MARKER).join(graphRaw.trim().replace(/<\/(script)/gi, '<\\/$1')); + + fs.writeFileSync(outFile, html); + const outAbs = path.resolve(outFile); + const kb = (fs.statSync(outAbs).size / 1024).toFixed(0); + + const s = graph.meta?.stats || {}; + console.log(`\nbrowsergnome perf-map`); + console.log(` wrote ${outAbs} (${kb} KB, standalone)`); + console.log(` ${s.nodes ?? '?'} modules · ${s.hotspots ?? '?'} hotspot(s) · ${s.findings ?? '?'} findings`); + if (rendersFile) { + const overlaid = graph.nodes.filter((n) => 'renders' in n).length; + console.log(` render overlay: ${overlaid} module(s)${unmatched.length ? ` · ${unmatched.length} profiled file(s) not in the graph: ${unmatched.join(', ')}` : ''}`); + } + if (graph.top3?.length) { + console.log(` Top-3:`); + for (const t of graph.top3) console.log(` ${t.rank}. ${t.paste} (debt ${t.debt})`); + } + console.log(` open with: open ${outAbs}\n`); + + if (open) { + const opener = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'cmd' : 'xdg-open'; + const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs]; + execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); }); + } +} + +main(); From bc4faf717b17add02f1719846d7c89a788d9c338 Mon Sep 17 00:00:00 2001 From: Xavier Costa Date: Tue, 3 Nov 2026 15:05:21 +0000 Subject: [PATCH 3/3] Harden script tag escaping in HTML builders Escape every `<` as `<` in all three builders, which also covers the `