Security Guide
MCP server CSS display:none security — direct element hiding, delayed toggle, @media viewport targeting, and animation-fill-mode:forwards keyframe collapse of consent disclosures
CSS display:none is the most fundamental visibility property in the CSS specification. When applied to an element it removes that element entirely from layout, painting, and the accessibility tree — yet the element remains fully present in the DOM with accessible textContent, queryable attributes, and normal event handlers. This distinction between DOM presence and visual rendering is the root cause of four distinct MCP server attack surfaces: direct application of display:none to the disclosure element, a delayed class toggle that fires after initial DOM audits complete, a @media query that targets the consent dialog's expected viewport width, and a CSS @keyframes animation combined with animation-fill-mode:forwards that collapses the disclosure after it briefly renders. All four attacks produce a disclosure element that passes DOM-based presence checks but is invisible to the user at the moment of consent.
Why display:none is both the simplest and most reliably detected attack — and why the variants are not
Direct display:none on a disclosure element is detectable: getComputedStyle(el).display === 'none' returns true, el.offsetHeight === 0 is true, and el.getBoundingClientRect() returns an all-zero rect. A simple computed-style check catches it. But the attack is rarely that simple in practice. The three variant forms — delayed toggle, responsive hiding, and animation collapse — each produce a disclosure that passes a computed-style check at the moment the audit runs, because at that moment the element is not yet hidden. The hiding occurs after the audit window closes, which in all three cases is measured in milliseconds or a single animation frame.
This timing dependency is what makes the variant forms dangerous. Static analysis of injected CSS can identify the rules. But runtime checks that sample the DOM at a single point in time — including all standard DOM-ready visibility checks — can miss the attack if sampling occurs before the hiding event fires. Correct detection requires either continuous monitoring, CSS pre-analysis that identifies dangerous rule patterns before they activate, or structural checks that verify not just current computed style but all CSS rules that could affect the element's display in any future state.
What display:none does and does not do: display:none removes the element from layout (no box generated), from painting (no pixels drawn), and from the accessibility tree (not exposed to AT). It does not remove the element from the DOM, does not clear textContent, does not remove event listeners, and does not affect querySelector() or getElementById() results. An audit that checks only DOM presence (element exists in document) will miss display:none entirely. An audit that checks computed display will catch direct application but not time-delayed or animation-terminated variants.
Attack 1: Direct display:none on the disclosure element
The most direct attack: the MCP server applies display:none to the disclosure element, either via an injected inline style attribute or via a CSS rule targeting the disclosure's identifier. The disclosure is in the DOM — it is part of the consent dialog HTML — but it has no visual presence whatsoever. The user sees the dialog, sees the "Accept" button, and sees whatever other content the MCP server chose to display in the dialog body. The disclosure text is absent from the rendered page.
Inline style injection is the most common implementation: the MCP server's JavaScript sets disclosureEl.style.display = 'none' after the dialog renders, or the MCP server delivers the disclosure HTML fragment with the inline attribute already set: <p class="disclosure" style="display:none">By accepting...</p>. CSS rule injection is subtler: a rule like .consent-dialog .disclosure { display: none !important; } added to an injected stylesheet is applied by the browser rendering engine without any JavaScript call on the element itself, making it harder to detect via element-level attribute monitoring.
/* Attack 1A: inline style injection */
disclosureEl.style.display = 'none';
/* OR: delivered in the HTML fragment */
/* <p class="disclosure" style="display:none">By accepting you grant...</p> */
/* Attack 1B: CSS rule injection via injected stylesheet */
const sheet = document.createElement('style');
sheet.textContent = '.consent-dialog .disclosure { display: none !important; }';
document.head.appendChild(sheet);
/* What DOM APIs report AFTER Attack 1: */
/* disclosureEl.textContent → "By accepting you grant MCP access to..." */
/* disclosureEl.getAttribute('class') → "disclosure" */
/* disclosureEl.querySelector('.disclosure') → the element itself */
/* document.getElementById('disclosure') → the element */
/* getComputedStyle(disclosureEl).display → "none" ← detectable HERE */
/* disclosureEl.offsetHeight → 0 ← detectable HERE */
/* disclosureEl.getBoundingClientRect() → { top:0, left:0, right:0, bottom:0 } ← detectable */
/* Detection of Attack 1: */
function isHidden(el) {
return getComputedStyle(el).display === 'none'
|| getComputedStyle(el).visibility === 'hidden'
|| getComputedStyle(el).opacity === '0';
}
/* Simple check — reliable for direct display:none, not for variants */
CSS rule injection is harder to detect than inline style injection: An inline style="display:none" attribute is visible in el.getAttribute('style') and in el.style.display. A CSS rule injected via a stylesheet has no reflection on the element's attributes — only getComputedStyle(el).display reveals the applied value. If an audit checks el.style.display rather than getComputedStyle(el).display, CSS rule injection passes undetected. Always use getComputedStyle() for computed value checks.
Attack 2: Delayed display:none toggle — class applied after one animation frame
The delayed variant exploits the timing window between when an audit check runs and when the disclosure becomes invisible. The disclosure renders initially with display:block — it is visible for approximately one animation frame (16ms at 60 fps). During this frame, any synchronous DOM audit check will see getComputedStyle(el).display === 'block' and report the disclosure as visible. Then, on the next animation frame, the MCP server's script executes a requestAnimationFrame callback that adds a CSS class to the disclosure — a class defined in an injected stylesheet as { display: none }. From the second frame onward, the disclosure is invisible. The user never reads it; the entire rendering lifetime of the visible state was a single frame.
This attack is particularly effective against audit systems that perform a one-shot DOM check at dialog open time. The check runs, sees a visible disclosure, records a pass, and the audit completes — all before the requestAnimationFrame callback fires. The hiding occurs in the same JavaScript event loop turn after the audit, but in a deferred rendering frame that the audit never observes.
/* Attack 2: delayed class toggle via requestAnimationFrame */
/* Injected stylesheet rule: */
/* .disclosure.hidden-after-render { display: none; } */
/* MCP server script — runs at dialog open time */
const disclosureEl = document.querySelector('.consent-dialog .disclosure');
/* Step 1: disclosure is visible (display:block) — audit may check here */
/* getComputedStyle(disclosureEl).display → "block" at this point */
/* Step 2: defer hiding to next animation frame */
requestAnimationFrame(() => {
disclosureEl.classList.add('hidden-after-render');
/* Now: getComputedStyle(disclosureEl).display → "none" */
/* But if audit ran in Step 1, it already recorded "visible" */
});
/* Even simpler variant: setTimeout with 0ms delay */
setTimeout(() => {
disclosureEl.style.display = 'none';
}, 0);
/* setTimeout 0ms fires after current callstack clears — after synchronous audit checks */
/* More aggressive variant: 100ms delay to clear async audit checks too */
setTimeout(() => {
disclosureEl.style.display = 'none';
}, 100);
/* Detection: MutationObserver watching for style/class changes after dialog open */
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
const el = mutation.target;
if (el === disclosureEl || disclosureEl.contains(el)) {
const display = getComputedStyle(disclosureEl).display;
if (display === 'none') {
/* Re-check after attribute change — hide detected post-render */
blockConsentInteraction('disclosure hidden after render: ' + mutation.attributeName);
}
}
}
}
});
observer.observe(consentDialog, { attributes: true, attributeFilter: ['style', 'class'], subtree: true });
The one-frame window is the attack: A disclosure that is visible for exactly one animation frame (16ms) is not meaningfully visible — no human can read a disclosure in 16ms. The purpose of the one-frame render is not to show the disclosure but to pass one-shot DOM audit checks that fire synchronously at dialog render time. Correct detection requires that the audit continues monitoring after the initial check, using a MutationObserver that watches for style and class changes on the disclosure element throughout the entire consent dialog's open lifetime.
Attack 3: @media query targeting the consent dialog's expected viewport
A @media query can make the disclosure visible only at viewport sizes that are atypical for the expected user context. The canonical attack: the disclosure is shown at very narrow mobile widths (max-width: 480px) but hidden at the desktop widths where users are most likely to use Claude Code and similar MCP-enabled tools. The injected CSS rule: @media (min-width: 481px) { .disclosure { display: none; } } — the disclosure is hidden on any viewport wider than 480px, which is approximately every desktop, tablet, and most laptop viewport.
The attack exploits the implicit assumption that most audit checks run on standard desktops or in headless Chromium at a default viewport size. If the audit runs at 1280×720 (a common default for automated testing), the media query fires and the disclosure is hidden. If the audit checks were run at 320×568 (mobile), the disclosure would be visible. The mismatch between the audit's test viewport and the actual user's viewport is the attack surface.
/* Attack 3: @media query hiding at typical desktop viewport */
/* Injected CSS: */
@media (min-width: 481px) {
.consent-dialog .disclosure {
display: none;
}
}
/* Effect: disclosure visible at ≤480px (narrow mobile), hidden at ≥481px (everything else) */
/* Audit running at 1280px viewport: disclosure hidden — audit passes incorrectly */
/* Actual user at 1440px laptop: disclosure hidden */
/* Alternate user at 375px iPhone: disclosure visible (but this is not the ICP user) */
/* Variant: target even the mobile widths, using landscape orientation */
@media (min-width: 481px), (orientation: landscape) {
.consent-dialog .disclosure { display: none; }
}
/* Now the disclosure is hidden on all landscape viewports (nearly all laptop/desktop) */
/* AND hidden at portrait widths above 480px */
/* Visible only at portrait mobile below 480px */
/* Variant: target by pointer type (desktop has 'fine' pointer) */
@media (pointer: fine) {
.consent-dialog .disclosure { display: none; }
}
/* pointer:fine = mouse or trackpad (desktop users) */
/* pointer:coarse = touchscreen (mobile users) */
/* Disclosure hidden for all desktop/laptop users, visible for mobile */
/* Detection: audit must check at multiple viewport sizes */
/* AND must inspect all @media rules in all stylesheets */
function findMediaQueryHiding(disclosureEl) {
const rules = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSMediaRule) {
for (const inner of rule.cssRules) {
if (disclosureEl.matches(inner.selectorText || '')) {
if ((inner.style.display || '') === 'none') {
rules.push({ media: rule.conditionText, selector: inner.selectorText });
}
}
}
}
}
} catch (e) { /* cross-origin sheet — flag for manual review */ }
}
return rules;
}
Cross-origin stylesheets block CSS rule inspection: document.styleSheets exposes CSSRule objects only for same-origin stylesheets. A stylesheet loaded from an external domain (including an MCP server's CDN) throws a SecurityError when cssRules is accessed. Static media query analysis therefore cannot inspect cross-origin stylesheets via JavaScript. Detection must combine stylesheet origin checking (flag any cross-origin stylesheet that could affect consent elements) with computed-style sampling across multiple viewport sizes using ResizeObserver or repeated checks at resized viewports.
Attack 4: CSS @keyframes animation with animation-fill-mode:forwards — disclosure fades to invisible after rendering
CSS animations can animate the display property between keyframes. More precisely, because display is a discrete property (not continuously interpolatable), a @keyframes rule can jump from display:block at the start to display:none at the end, with the transition occurring at the 50% mark of the animation duration. Combined with animation-fill-mode: forwards, the element remains at its final keyframe state (display:none) after the animation ends, permanently.
The attack sequence: the disclosure element is given an animation of duration 100ms (or any short but non-zero duration). During the first 50ms, the element is display:block — it is present in the render. At the 50ms mark, the display property discretely switches to none. At 100ms (animation end), the animation stops, and animation-fill-mode: forwards locks the element in the final state: display:none. The disclosure was visible for 50ms — not enough for any user to read it — and is now permanently hidden. Any audit that ran during the first 50ms window would have seen display:block and passed. Any audit that runs after 50ms sees display:none.
/* Attack 4: @keyframes animation collapsing disclosure to display:none */
/* Injected CSS: */
@keyframes hide-disclosure {
from { display: block; } /* 0–50%: disclosure visible */
to { display: none; } /* 50–100%: disclosure hidden */
}
.consent-dialog .disclosure {
animation-name: hide-disclosure;
animation-duration: 0.1s; /* 100ms total — short but measurable */
animation-fill-mode: forwards; /* stays at final keyframe: display:none */
animation-timing-function: step-end; /* jump to 'none' at end, not mid-way */
}
/* With step-end: disclosure is display:block for the full 100ms, */
/* then jumps to display:none at the animation end. */
/* Visible window: 100ms after dialog open. */
/* More aggressive: animation-duration:0.016s (one frame) + step-end */
/* Visible window: 1 frame (16ms). Passes any synchronous audit check. */
/* Variant using animation-delay for even more deceptive timing: */
.disclosure {
display: none; /* initially hidden */
animation-name: show-then-hide;
animation-duration: 0.5s;
animation-delay: 0.5s; /* starts 500ms after dialog open */
animation-fill-mode: both; /* 'both' = backwards fills initial state too */
}
@keyframes show-then-hide {
0% { display: block; } /* visible from 500ms to 750ms */
50% { display: block; }
51% { display: none; } /* hidden from 750ms onward */
100% { display: none; }
}
/* Effect: disclosure is initially hidden, becomes visible at 500ms (when user */
/* is already reading other dialog content), then hides at 750ms. */
/* Any audit checking at t=0 sees display:none. */
/* Any audit checking at t=600ms sees display:block. */
/* User never reads it because the visible window is during a busy UI moment. */
/* Detection: check for animation properties on disclosure element */
function hasCollapsingAnimation(el) {
const style = getComputedStyle(el);
const animName = style.animationName;
const fillMode = style.animationFillMode;
const duration = parseFloat(style.animationDuration);
if (animName === 'none' || !animName) return false;
/* Flag: any animation with fill-mode forwards/both on a disclosure element */
if (fillMode === 'forwards' || fillMode === 'both') {
/* Check if animation ends at display:none by inspecting keyframe rules */
return { suspicious: true, animName, fillMode, duration };
}
return false;
}
Animation-based hiding is almost impossible to catch with one-shot checks: The visible window can be as short as one animation frame (16ms). Any point-in-time DOM check that happens to run outside the animation's "visible" phase will see either the initial state (hidden before animation) or the final state (hidden after animation). The only reliable detection is to inspect the @keyframes rules themselves and flag any animation applied to a consent disclosure element that has animation-fill-mode: forwards or both and includes display:none in any keyframe.
Detection matrix — which checks catch each attack variant
| Check | Attack 1: direct display:none | Attack 2: delayed toggle | Attack 3: @media hiding | Attack 4: animation collapse |
|---|---|---|---|---|
el.style.display === 'none' |
Catches inline style only | Miss (catches only after toggle) | Miss (no inline style) | Miss (no inline style) |
getComputedStyle(el).display === 'none' |
Catches (correct detection) | Catches only after delay fires | Catches at correct viewport only | Catches only after animation end |
el.offsetHeight === 0 |
Catches (correct detection) | Catches only after delay fires | Catches at correct viewport only | Catches only after animation end |
| MutationObserver (style/class changes) | Catches (if watching before injection) | Catches class/style change event | Miss (@media changes do not fire mutations) | Miss (CSS animations do not fire mutations) |
| CSS rule inspection (stylesheet @media scan) | Catches CSS-rule injection, not inline | Catches the class rule if identifiable | Catches @media rule (same-origin only) | Catches @keyframes referencing display:none |
| Multi-viewport sampling | Catches at all viewports | Miss (timing-based, not viewport-based) | Catches by comparing across viewports | Miss (timing-based) |
| Animation property inspection | Not applicable | Not applicable | Not applicable | Catches fill-mode:forwards on disclosure |
No single check catches all four attacks. Complete detection requires: (1) getComputedStyle check at dialog open time, (2) MutationObserver monitoring throughout dialog lifetime, (3) CSS @media rule scanning across all same-origin stylesheets, (4) CSS @keyframes animation inspection for animation-fill-mode:forwards rules on disclosure elements, and (5) a second getComputedStyle check immediately before any consent interaction is recorded.
Defense — host-side and platform-level mitigations
CSP style-src and style nonces
A Content-Security-Policy header with style-src 'self' 'nonce-{random}' prevents unauthorized stylesheet injection. Only stylesheets delivered by the origin server or carrying the current page's nonce are applied. Injected <style> elements without the nonce are blocked before they reach the CSS cascade, eliminating all four attack surfaces that depend on injecting CSS rules. Inline style attributes set via JavaScript (Attack 1A) are separately controlled by the 'unsafe-inline' or 'unsafe-hashes' directives — omitting both prevents inline style injection too, though this requires framework support for nonce-based inline styles.
Explicit display declaration on the disclosure element
Adding !important to the host's own display declaration on the disclosure element prevents CSS injection from overriding it, since injected rules at the same specificity lose to !important. However, a higher-specificity injected rule (body .consent-dialog .disclosure { display: none !important; }) still wins. The only reliable CSS-cascade defense against !important injection is Shadow DOM encapsulation, which prevents external stylesheets from reaching shadow DOM elements entirely.
Re-check immediately before consent recording
Regardless of what checks run at dialog open time, re-run the full disclosure visibility check immediately before recording any consent interaction — at the moment the user clicks "Accept" or the equivalent action. Call getComputedStyle(disclosureEl).display, verify it is not none, and also check offsetHeight > 0. If the disclosure is hidden at the time of consent, block the interaction and log the finding. This "last-mile" check catches delayed toggles (Attack 2) and animations (Attack 4) that fire after the initial audit but before the user acts.
/* Last-mile check: verify disclosure is visible at consent interaction time */
consentAcceptButton.addEventListener('click', (e) => {
const disclosure = document.querySelector('.consent-dialog .disclosure');
if (!disclosure) {
e.preventDefault();
logSecurityFinding('consent_disclosure_missing', { at: 'click' });
return;
}
const style = getComputedStyle(disclosure);
const hidden = style.display === 'none'
|| style.visibility === 'hidden'
|| parseFloat(style.opacity) === 0
|| disclosure.offsetHeight === 0;
if (hidden) {
e.preventDefault();
logSecurityFinding('consent_disclosure_hidden_at_click', {
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
offsetHeight: disclosure.offsetHeight
});
return;
}
/* Disclosure is visible at click time — proceed with consent recording */
recordConsentEvent();
}, { capture: true }); /* capture: true fires before any other click handlers */
SkillAudit findings
.consent-dialog .disclosure) has getComputedStyle().display === 'none'. Inline style attribute: style="display:none" set via disclosureEl.style.display = 'none' in the MCP server's initialization script. Element has textContent: "By accepting this connection you authorize read access to your active files and clipboard contents" and offsetHeight: 0. Hiding detected at dialog open time. Consent interaction blocked pending disclosure visibility restoration.
display:block at dialog-open time (passes initial check). MutationObserver detected class hidden-after-render added to disclosure element 48ms after dialog open. After class addition, getComputedStyle().display === 'none' (rule: .disclosure.hidden-after-render { display: none; } in injected stylesheet). Disclosure was visible for 48ms — insufficient for any user to read the 42-word disclosure text. Attack vector: requestAnimationFrame callback in MCP server initialization. Disclosure treated as hidden at time of consent interaction.
@media (min-width: 481px) { .disclosure { display: none; } }. At audit viewport (1280px): getComputedStyle(disclosure).display === 'none' — media query fires, disclosure hidden. At 320px test viewport: display:block — media query does not fire. Disclosure is hidden for all viewport widths above 480px. All desktop, laptop, and tablet users (estimated 94% of target ICP) experience a hidden disclosure. Cross-origin stylesheet: access to cssRules blocked by same-origin policy; media query detected via computed-style sampling across 320px, 768px, 1280px viewports.
animation-name: reveal-then-hide; animation-duration: 0.08s; animation-fill-mode: forwards. Keyframe rule @keyframes reveal-then-hide: 0–50% display:block, 50–100% display:none. At t=0: display:block (pre-animation initial). At t=40ms: display:block (within animation). At t=80ms: display:none (animation ended, fill-mode:forwards). Disclosure visible for approximately 40ms. Animation fill-mode forwards with display:none keyframe flagged in static CSS inspection. Disclosure treated as hidden because animation permanently collapses it before any user interaction is possible.
Audit your MCP server integrations with SkillAudit. SkillAudit's consent integrity scanner checks all four display:none attack surfaces — direct hiding, delayed class toggles, @media viewport targeting, and animation collapse — via both static CSS analysis and runtime computed-style monitoring throughout the dialog's open lifetime. A last-mile re-check fires at every consent interaction to catch attacks that activate after the initial audit window. See audit plans or browse existing audits for full coverage details.