MCP server SVG set element security
The SVG <set> element is a SMIL animation element that performs a discrete, non-interpolated attribute change at a specified trigger event. Unlike <animate>, which changes an attribute through a visible range of intermediate values, <set> has no from value — it simply assigns a new value at the trigger instant with no animation frames in between. With fill="freeze" the assigned value persists after the trigger fires. This makes <set> elements ideal attack vectors for consent-text manipulation: the change is instantaneous, leaves no visible animation trace, and the attribute appears at its normal value in a static DOM snapshot taken before the trigger event.
Finding SA-SET-001: visibility="hidden" triggered at consentForm.mouseenter with fill=freeze
<set> element targets the consent text element with attributeName="visibility" to="hidden" and begin="consentForm.mouseenter". When the user moves their mouse toward the consent form — the interaction gesture that precedes clicking Agree — the consent text is immediately hidden via the discrete set. The button remains visible and clickable. With fill="freeze" the text stays hidden for the duration of the user's interaction. A static DOM audit reads visibility="visible" because the set element has not fired; a live audit must simulate the mouseenter event to detect the triggered state change.<text id="consentText" x="20" y="40" font-size="14" fill="#1a1a1a"
visibility="visible">
I authorize all requested MCP server permissions including file and network access
<!-- set fires when user approaches the form — just before clicking Agree -->
<set attributeName="visibility" to="hidden"
begin="consentForm.mouseenter" fill="freeze"/>
</text>
<rect id="agreeBtn" x="240" y="55" width="120" height="36"
fill="#4f46e5" rx="6"/>
<text x="300" y="79" font-size="14" fill="white" text-anchor="middle"
pointer-events="none">Agree</text>
The consent text is fully visible when the page loads and when the DOM is snapshotted for a static audit. The visibility="visible" attribute is present and correct in the markup. The <set> child element hides the parent the moment the user moves their mouse into the consent form area — exactly when they are reading it before clicking. After mouseenter fires, fill="freeze" keeps the text hidden until the page reloads. The button is unaffected and the click is still recorded as consent.
Finding SA-SET-002: display="none" triggered at agreeBtn.focus
<set> element targets the consent description element with attributeName="display" to="none" and begin="agreeBtn.focus". When the user tabs to or clicks the Agree button — the focus event fires immediately before the click — the consent text is removed from the layout via display="none". Unlike visibility="hidden", display="none" removes the element from the layout flow, potentially reflow-collapsing the containing element. The consent text is present and visible during page load; it disappears the instant the user is about to confirm. A static scan reads display as unset (defaults to inline).<g id="consentContainer">
<rect width="380" height="80" fill="white"/>
<text id="consentDesc" x="10" y="30" font-size="13" fill="#374151">
Granting access allows this MCP server to read all files in your home directory
<!-- display=none fires when user focuses the Agree button -->
<set attributeName="display" to="none"
begin="agreeBtn.focus" fill="freeze"/>
</text>
<rect id="agreeBtn" x="260" y="50" width="110" height="26"
fill="#059669" rx="4"/>
<text x="315" y="68" font-size="13" fill="white" text-anchor="middle"
pointer-events="none">Allow</text>
</g>
display="none" is a stronger hide than visibility="hidden": the element is removed from the SVG rendering tree entirely. The layout of surrounding elements may shift. The display attribute on SVG elements is a presentation attribute with the same effect as the CSS display property — a set element can flip it discretely from the default (visible) to none in one event. Browsers support this without error; the DOM shows the text node; textContent returns the consent string. Only evaluating the set element's trigger event reveals the attack.
Finding SA-SET-003: fill="white" overwrites consent text color to match background
<set> element targets the consent text element with attributeName="fill" to="white" and begin="consentForm.mouseenter". The SVG background is white. When the trigger fires, the consent text becomes white-on-white — invisible by color blending rather than by removing the element from the layout. The element remains in the DOM with its fill attribute now reading white in the live DOM (after the event), but reads as the original dark color in a static snapshot. The text is still selectable and present in accessibility trees; only visual rendering is affected.<svg viewBox="0 0 400 100" style="background:#ffffff">
<text x="10" y="35" font-size="14" fill="#1a1a1a" id="disclosure">
By clicking Agree you grant this MCP server read/write access to your filesystem
<!-- set fires on mouseenter: text becomes white on white background -->
<set attributeName="fill" to="white"
begin="consentForm.mouseenter" fill="freeze"/>
</text>
<!-- Button is unaffected — its fill is set independently -->
<rect id="agreeBtn" x="270" y="60" width="120" height="32"
fill="#2563eb" rx="6"/>
<text x="330" y="82" font-size="14" fill="white" text-anchor="middle"
pointer-events="none">Agree</text>
</svg>
The fill-to-background-color technique is harder to detect automatically than visibility or display changes because the "attack" value is a legal color, not a toggle. An automated check that reads fill before the trigger event sees #1a1a1a — a valid dark color. Detection requires: (1) enumerating all <set> elements targeting fill on consent text, (2) determining the SVG background color (which may be inherited from CSS or set on the SVG element), and (3) checking whether the to value matches or closely approximates the effective background color.
Finding SA-SET-004: opacity="0" triggered at form interaction begin event
<set> element targets the consent text's parent group with attributeName="opacity" to="0" and a compound begin condition combining a form interaction event with a short delay: begin="agreeBtn.click - 0.1s". This fires 100ms before the click completes, ensuring the consent text group is invisible when the click is recorded. With fill="freeze" the group remains at opacity 0. The opacity attribute on the group affects all child elements including the consent text; the group itself may still pass a per-element opacity check if the check targets the text element directly rather than its ancestors.<g id="consentGroup" opacity="1">
<!-- set fires 100ms before the click event completes -->
<set attributeName="opacity" to="0"
begin="agreeBtn.click - 0.1s" fill="freeze"/>
<text x="10" y="30" font-size="14" fill="#111827">
I acknowledge this grants full filesystem access to the MCP server
</text>
<text x="10" y="52" font-size="12" fill="#6b7280">
Including reading credentials, keys, and private documents
</text>
</g>
<rect id="agreeBtn" x="260" y="60" width="120" height="32"
fill="#dc2626" rx="6"/>
<text x="320" y="82" font-size="13" fill="white" text-anchor="middle"
pointer-events="none">I Agree</text>
The negative-delay begin expression agreeBtn.click - 0.1s fires 100ms before the click event resolves. At the moment the click handler runs and records consent, the consent group is already at opacity 0. This is a click-window attack: the element is visible during the page's initial render (while the user is reading), becomes invisible as the user's click gesture begins, and the consent is recorded against invisible disclosure. The 100ms window is typically imperceptible to users.
set vs animate: why set is the stealthier attack vector
| Property | <animate> | <set> | Audit implication |
|---|---|---|---|
| Intermediate states | Yes — visible transition from from to to |
No — instant discrete jump | set leaves no animation artifact for visual inspection |
| from attribute | Required or computed from current value | Not present — only to |
Static checks see only the original attribute value, not the to value |
| Duration | Required — specifies animation length | Instantaneous — dur attribute is ignored | No duration to flag; change happens in zero perceived time |
| fill="freeze" persistence | Present but often set to "remove" in legitimate animations | fill="freeze" is common and expected pattern for set | freeze on set is less suspicious-looking than freeze on animate |
| targetAttribute coverage | Numeric/color attributes only (browser-dependent) | Any SVG presentation attribute including visibility, display, opacity, fill | set can target visibility and display which animate cannot always address |
Detection algorithm: auditing SVG set elements on consent subtrees
| Step | Action | What it catches |
|---|---|---|
| 1 | Collect all <set> elements in the SVG document. For each, identify the target element via attributeName on the parent or the xlink:href/href attribute pointing to a target element ID |
Enumerates all set-based discrete attribute changes in the document |
| 2 | Check whether the target element is within the consent subtree (ancestor is the consent container, or the element is the consent text / a group wrapping consent text) | Filters to set elements that affect consent presentation |
| 3 | For qualifying set elements, read attributeName and to. Flag Critical if attributeName ∈ {visibility, display, opacity} and to is a hide value (hidden, none, 0) |
SA-SET-001, SA-SET-002, SA-SET-004: direct hide attacks |
| 4 | For attributeName="fill", compare to value against the effective SVG background color and any parent fill values. Flag High if to matches or approximates background (within WCAG contrast threshold of 1.1:1) |
SA-SET-003: color-blend consent text disappearance |
| 5 | Parse the begin attribute for event triggers. Flag any trigger whose event type correlates with consent interaction gestures: mouseenter, mouseover, focus, click (especially with negative offsets) |
Identifies interaction-correlated triggers that time the hide to the consent gesture |
| 6 | Check fill="freeze" on qualifying set elements. fill="freeze" makes the attribute change permanent until the page reloads; fill="remove" reverts the change. Flag freeze as higher severity (persistent hide) |
Distinguishes temporary flicker from persistent consent-text removal |
SkillAudit identifies all <set> elements in consent subtrees, evaluates their target attribute and value against a hide-patterns library, and classifies trigger event types for interaction-correlated timing. Both static (attribute value before trigger) and dynamic (attribute value after trigger simulation) states are compared. Run a free audit on your MCP server's GitHub URL.