From 89a6b947e2711a3bbaf36a33ed8a72789e7d60e0 Mon Sep 17 00:00:00 2001 From: sec-check Date: Thu, 15 Oct 2026 08:48:53 -0400 Subject: [PATCH 1/2] [sec-check] fix: re-scan the SVG stripActiveContent returns stripActiveContent() documents that it re-scans its result and throws if active content survives, and two mirroring callers write that result into static/ with no active-content check of their own. The re-scan was never implemented. Add it, and cover the post-condition for every strippable construct. Signed-off-by: sec-check --- scripts/lib/svg-active-content.mjs | 17 +++++++- tests/svg-active-content.test.mjs | 69 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/scripts/lib/svg-active-content.mjs b/scripts/lib/svg-active-content.mjs index 32eda772..8daf29f1 100644 --- a/scripts/lib/svg-active-content.mjs +++ b/scripts/lib/svg-active-content.mjs @@ -791,8 +791,21 @@ export function stripActiveContent(source) { } // Findings and removals both come from classifyTag() over this same parse, - // and the output is serialized from the tree, so what is left is exactly the - // nodes that produced no finding. + // and the output is serialized from the tree, so what is left should be + // exactly the nodes that produced no finding. That is an argument about two + // code paths agreeing, not a property of the bytes being returned, and the + // bytes are what callers publish at the site origin: architecture-content.mjs + // and enduser-collector.mjs write this string into static/ with no active + // content check of their own. Re-scan the actual output so any future + // divergence between classifyTag()'s removal set, rebuild()'s serialization + // and findActiveContent()'s detection fails closed here instead of + // publishing a document that only looks sanitized. const result = rebuild(text, parsed.events, { strip: true }); + const residual = findActiveContent(result.source); + if (residual.length) { + throw new Error( + `Active content survived stripping from SVG: ${residual.join('; ')}`, + ); + } return result; } diff --git a/tests/svg-active-content.test.mjs b/tests/svg-active-content.test.mjs index a838e26e..ea86d2de 100644 --- a/tests/svg-active-content.test.mjs +++ b/tests/svg-active-content.test.mjs @@ -781,6 +781,75 @@ test('throws rather than returning a source the detector still flags', () => { }); }); +// The test above covers the structural refusal, which happens *before* the +// document is rebuilt. This one covers the other half of the same promise: a +// document that is well-formed enough to edit still has to come back inert. +// architecture-content.mjs (mirrorArtworkUrls) and enduser-collector.mjs +// (mirrorLandscapeLogo) write this return value straight into static/ and run +// only findRemoteReferences() over it, so stripActiveContent() is the sole +// active-content gate on the bytes those two paths publish at the site origin. +// Asserting the post-condition over every construct the module strips keeps a +// future change to classifyTag()'s removal set, rebuild()'s serialization or +// findActiveContent()'s detection from silently drifting apart. +const STRIPPABLE_CONSTRUCTS = [ + [''], + [' element', 'alert(1)'], + [' element', ''], + [ + '