MCP server CSS display:contents consent security
CSS display:contents makes an element act as if it is not there from a box-model perspective: the element's box is removed from the layout, but its children are kept in the flow as direct children of the element's parent. There is no background rendering, no border, no padding box, and no click-target area for the element itself. Applied to a consent wrapper, this removes the consent section's visual identity while leaving text nodes as unstyled flow content. Applied to the consent text element itself, the text node loses its layout box and may not render at all in some implementations.
Attack findings
Background: display:contents box removal
The CSS display:contents value was introduced to support cases where a wrapper element is needed for semantic or JavaScript purposes but should not contribute a box to the layout. A <ul> with display:contents allows its <li> children to participate in an outer CSS Grid layout as direct grid items. The display value is legitimate and common in modern component-based web development. For consent security, its box-removal semantics create a set of exploitable side effects distinct from display:none (which removes both box and children) or visibility:hidden (which hides the box but keeps it in layout).
Audit gap: A static check for display:none on the consent element passes when display:contents is applied — display:contents is not "none". An accessibility-tree check may also pass because text content is technically still in the document. The failure mode is visual: text renders unstyled, borderless, without background contrast, and without a click surface, often becoming illegible in context.
Attack 1 — display:contents removes consent wrapper box (SA-CSS-DC-001)
The consent wrapper is typically authored with a styled background (a card, a modal section, a highlighted box) that visually frames the consent text and distinguishes it from surrounding content. display:contents removes this framing entirely: no background, no border, no padding. The consent text children are promoted to anonymous flow content and styled only by what they inherit from the grandparent element — which may be the main page content color and font-size, indistinguishable from body text. Without the visual container that signals "this is a consent disclosure", users may not recognize the text as requiring attention.
/* Attack: display:contents removes consent box — text blends into page */
.consent-wrapper {
display: contents;
/* No box rendered: no background-color, no border, no padding, no click target */
/* Text children inherit from .consent-wrapper's parent (e.g., .install-form) */
/* If install-form has color:#888 (muted text) — consent inherits muted color */
}
/* Detection */
function checkDisplayContents(el) {
const cs = getComputedStyle(el);
if (cs.display === 'contents') {
return { vuln: 'SA-CSS-DC-001',
detail: 'consent wrapper has display:contents — box removed, no click target, no inherited styles' };
}
// Also check ancestors
let ancestor = el.parentElement;
while (ancestor) {
if (getComputedStyle(ancestor).display === 'contents') {
return { vuln: 'SA-CSS-DC-001',
detail: `consent ancestor ${ancestor.tagName} has display:contents` };
}
ancestor = ancestor.parentElement;
}
return null;
}
SA-CSS-DC-001 (High). Detection requires checking the computed display value of the consent element and each of its ancestors. display:contents on any ancestor removes that ancestor's box and changes the inheritance chain for text properties. SkillAudit checks all consent element ancestors for display:contents.
Attack 2 — display:contents on the consent text element removes layout box (SA-CSS-DC-002)
The HTML specification is ambiguous about whether display:contents on a replaced element (like <img>) or an element with no children (like an empty <span>) should produce any rendering. In practice, Chromium and Firefox remove the element from the accessibility tree and produce no bounding box for elements with display:contents and no children. For a consent text element — a <p> or <span> — setting display:contents makes the text node the only remaining content, which in some browser implementations renders without a box, making getBoundingClientRect() return 0×0. This makes the consent text functionally invisible despite innerText returning the full consent string.
/* Attack: display:contents on consent text element removes layout box */
.consent-text {
display: contents;
/* Text node rendered without a box in some implementations */
/* getBoundingClientRect(): { width: 0, height: 0 } */
/* innerText: full consent string — DOM audit: PASS */
/* visual: no rendered box — effectively invisible */
}
/* Detection */
const consentEl = document.querySelector('.consent-text');
const rect = consentEl.getBoundingClientRect();
const hasText = (consentEl.textContent || '').trim().length > 0;
const display = getComputedStyle(consentEl).display;
if (display === 'contents' && hasText && rect.width === 0) {
flagFinding('SA-CSS-DC-002',
'display:contents on consent text element — zero bounding box despite text present');
}
Attack 3 — display:contents + parent near-white bypasses wrapper color declaration (SA-CSS-DC-003)
CSS inheritance for display:contents elements is defined as: the element's own style declarations (including inherited properties like color and font-size) do not participate in the inheritance cascade. Property values inherit directly from the element's parent, skipping the display:contents element's own declarations. When an MCP server sets the consent wrapper's display:contents, any consent-specific styling applied to the wrapper (such as color: var(--fg) for legibility) is bypassed. If the parent element has color: #e0e0e0 (near-white), the text children inherit this near-white color directly, overriding the wrapper's intended color declaration.
/* Attack: display:contents bypasses wrapper color — children inherit parent near-white */
.install-section {
color: #e8e8e8; /* Near-white text for "decorative" section text */
background: #ffffff;
}
.consent-wrapper {
display: contents;
color: var(--fg); /* This declaration is BYPASSED — display:contents skips own styles */
/* Children inherit color:#e8e8e8 from .install-section directly */
/* Near-white on white background: ~1.2:1 contrast — invisible */
}
Attack 4 — JS-applied display:contents after load delay (SA-CSS-DC-004)
Static analysis reads the stylesheet and initial computed styles at parse time. JavaScript that sets el.style.display = 'contents' after a 500ms delay (DOMContentLoaded + setTimeout) applies display:contents after the initial style snapshot. The consent wrapper renders correctly for the first 500ms — long enough for a static auditor to record "consent visible" — then the JS fires, removes the wrapper box, and the consent blends into the page at the same time the user is engaging with the install flow. This pattern also evades server-side rendering snapshots that capture the initial HTML + CSS state without executing JavaScript.
/* Attack: JS applies display:contents after initial render */
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
const wrapper = document.querySelector('.consent-wrapper');
if (wrapper) wrapper.style.display = 'contents';
}, 500);
});
/* Detection: MutationObserver on style attribute */
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
if (m.target.style.display === 'contents') {
const isConsentEl = m.target.closest('[class*="consent"]');
if (isConsentEl) flagFinding('SA-CSS-DC-004',
'display:contents applied to consent element at runtime');
}
}
}
});
observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] });
SkillAudit detection: SkillAudit checks the computed display value of all consent elements and their ancestor chain for display:contents, evaluates bounding box dimensions, monitors runtime style mutations, and checks text color inheritance from the effective parent when display:contents is present. Run a free audit →
Detection summary
| Attack ID | display:contents target + mechanism | Key detection signal |
|---|---|---|
| SA-CSS-DC-001 | display:contents on consent wrapper → box/click-target removed | getComputedStyle(wrapper).display === 'contents' |
| SA-CSS-DC-002 | display:contents on consent text → zero bounding box | display:contents on text element + getBoundingClientRect() = 0×0 |
| SA-CSS-DC-003 | display:contents + parent near-white → color inheritance bypass | display:contents present; parent color near-white; consent text contrast < 4.5:1 |
| SA-CSS-DC-004 | JS display:contents applied at runtime → static audit miss | MutationObserver detects style.display = 'contents' mutation on consent element |