Finding
scripts/lib/svg-active-content.mjs exposes a detect/clean pair over the same threat model: findActiveContent reports active content in an untrusted SVG, stripActiveContent removes it. For value-less and backtick-delimited event handlers the two disagree — detection fires, removal does nothing, and removed comes back empty so the caller has no signal that anything was left behind.
findActiveContent has an explicit fallback for handlers the attribute scanner cannot see (lines 143-146):
// Catch unquoted/malformed handler attributes the attribute scanner misses.
if (!handlers.size && EVENT_HANDLER_ATTRIBUTE.test(source)) {
handlers.add('on*');
}
stripActiveContent has no equivalent. It rewrites only through ATTRIBUTE_PATTERN (line 207), which requires an attribute to have a quoted or unquoted value — precisely the case the fallback exists to cover.
Reproduction
Verified at rev 03ccfacf2ab27aa2e32554132939f7e6b0ce6e78 on node v26.8.2:
import { findActiveContent, stripActiveContent } from './scripts/lib/svg-active-content.mjs';
const result = stripActiveContent('<svg onload=></svg>');
console.log(result.source); // '<svg onload=></svg>' — unchanged
console.log(result.removed); // [] — reports nothing removed
console.log(findActiveContent(result.source));
// [ 'contains event handler attribute(s): on*' ] — still active after stripping
The same holds for <svg onload=`alert(1)`></svg>, and for <svg onload= ></svg>.
Running the stripped output back through findActiveContent still reports active content. That round-trip — strip, then re-detect and expect nothing — is the invariant being violated, and it is the cheapest way to express the fix's acceptance criterion.
Why it matters
A caller that strips and then trusts the result will write an SVG that still carries an onload= attribute, while removed being empty makes the operation look like a no-op on an already-inert file. The module header is explicit that a browser loading such an SVG directly executes its script in the serving origin, so this is the exact outcome the module exists to prevent. An attacker controlling an imported asset only has to omit the handler's value.
Whether any asset in the current import corpus exercises this is not the point: the guard is written to be robust against hostile input, and here it silently is not.
Recommendation
Give stripActiveContent the same fallback reach as findActiveContent. A minimal fix is to run a handler-removal pass over the source before the ATTRIBUTE_PATTERN rewrite, using the same EVENT_HANDLER_ATTRIBUTE shape the detector relies on, and to push an entry onto removed whenever it fires — so a caller inspecting removed sees the removal.
The fix belongs in scripts/lib/svg-active-content.mjs, which is production code and therefore outside the quality agent's lane. This needs a human maintainer or an agent permitted to change production code to land.
Ready-made regression test
A todo-marked test pinning this defect is included in the PR for #539 (following the convention already used in tests/profile-links.test.mjs:111 for #504). Once the production fix lands, dropping { todo: true } from that test is the whole verification step:
Priority
- Impact: high — the sanitiser reports success while leaving an executable handler in place
- Effort: low — one additional removal pass in a single function
Filed by quality agent (hold-gated mode)
🐝 Hive Agent: quality | Instance: hosted-available-lke648397-260827-5n31 | SHA: 03ccfac
— hive: agent=quality backend=copilot model=claude-opus-5 copilot=1.0.88
Finding
scripts/lib/svg-active-content.mjsexposes a detect/clean pair over the same threat model:findActiveContentreports active content in an untrusted SVG,stripActiveContentremoves it. For value-less and backtick-delimited event handlers the two disagree — detection fires, removal does nothing, andremovedcomes back empty so the caller has no signal that anything was left behind.findActiveContenthas an explicit fallback for handlers the attribute scanner cannot see (lines 143-146):stripActiveContenthas no equivalent. It rewrites only throughATTRIBUTE_PATTERN(line 207), which requires an attribute to have a quoted or unquoted value — precisely the case the fallback exists to cover.Reproduction
Verified at rev
03ccfacf2ab27aa2e32554132939f7e6b0ce6e78on node v26.8.2:The same holds for
<svg onload=`alert(1)`></svg>, and for<svg onload= ></svg>.Running the stripped output back through
findActiveContentstill reports active content. That round-trip — strip, then re-detect and expect nothing — is the invariant being violated, and it is the cheapest way to express the fix's acceptance criterion.Why it matters
A caller that strips and then trusts the result will write an SVG that still carries an
onload=attribute, whileremovedbeing empty makes the operation look like a no-op on an already-inert file. The module header is explicit that a browser loading such an SVG directly executes its script in the serving origin, so this is the exact outcome the module exists to prevent. An attacker controlling an imported asset only has to omit the handler's value.Whether any asset in the current import corpus exercises this is not the point: the guard is written to be robust against hostile input, and here it silently is not.
Recommendation
Give
stripActiveContentthe same fallback reach asfindActiveContent. A minimal fix is to run a handler-removal pass over the source before theATTRIBUTE_PATTERNrewrite, using the sameEVENT_HANDLER_ATTRIBUTEshape the detector relies on, and to push an entry ontoremovedwhenever it fires — so a caller inspectingremovedsees the removal.The fix belongs in
scripts/lib/svg-active-content.mjs, which is production code and therefore outside the quality agent's lane. This needs a human maintainer or an agent permitted to change production code to land.Ready-made regression test
A
todo-marked test pinning this defect is included in the PR for #539 (following the convention already used intests/profile-links.test.mjs:111for #504). Once the production fix lands, dropping{ todo: true }from that test is the whole verification step:stripActiveContentremoves a value-less event handler and records it inremovedstripActiveContentremoves a backtick-delimited event handler and records it inremovedfindActiveContent(stripActiveContent(src).source)is empty for both inputs{ todo: true }removed from the corresponding test intests/svg-active-content.test.mjsPriority
Filed by quality agent (hold-gated mode)
🐝 Hive Agent:
quality| Instance:hosted-available-lke648397-260827-5n31| SHA:03ccfac— hive: agent=quality backend=copilot model=claude-opus-5 copilot=1.0.88