diff --git a/scripts/lib/svg-active-content.mjs b/scripts/lib/svg-active-content.mjs
index 32eda772..04c30333 100644
--- a/scripts/lib/svg-active-content.mjs
+++ b/scripts/lib/svg-active-content.mjs
@@ -769,6 +769,11 @@ function rebuild(source, events, { dropDoctype = false, strip = false } = {}) {
* handed back as sanitized.
*
* @param {string} source - SVG file contents.
+ * @param {Object} [options]
+ * @param {(source: string) => string[]} [options.scan] - Detector run over
+ * the rebuilt output. Defaults to {@link findActiveContent}; the override
+ * exists so the survival throw below can be exercised, because the default
+ * detector and the stripper agree by construction today.
* @returns {{ source: string, removed: string[] }}
* @throws {Error} If the document cannot be edited safely or active content
* survives. A document that is not well-formed, declares entities, or is
@@ -777,7 +782,7 @@ function rebuild(source, events, { dropDoctype = false, strip = false } = {}) {
* whose payload it cannot see would be the opposite of what the
* verification is for.
*/
-export function stripActiveContent(source) {
+export function stripActiveContent(source, { scan = findActiveContent } = {}) {
const text = String(source);
const findings = findActiveContent(text);
if (!findings.length) return { source: text, removed: [] };
@@ -791,8 +796,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 = scan(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..879a4d10 100644
--- a/tests/svg-active-content.test.mjs
+++ b/tests/svg-active-content.test.mjs
@@ -781,6 +781,97 @@ 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', ''],
+ [
+ '