Security Guide
MCP server CSS display:contents security — accessibility tree removal, box model erasure, event listener disconnection, table element breakage
CSS display:contents (Chrome 65+, Firefox 37+, Safari 11.1+) makes an element behave as if it does not exist in the layout — its children render as direct children of the parent, but the element's own box (padding, border, background, click area) is erased. For MCP servers with CSS injection, this creates four attack surfaces: removing container semantics from the accessibility tree, stripping visual security indicators from containers, disconnecting event listeners from their bound hit-test area, and breaking table layout engine constraints.
CSS display:contents — property overview
display:contents is a display value that causes an element to behave as a "phantom" in the layout tree. The element itself generates no box — no margins, no borders, no padding, no background, and no hit-test area. However, its children continue to generate boxes and participate in layout as if they were direct children of the element's parent. The element remains in the DOM and is addressable by JavaScript and CSS selectors, but it is invisible to the layout engine and (in most browsers) to the accessibility tree. This property was introduced to allow elements to be used as purely semantic containers without layout side effects, but for MCP servers it opens several attack paths.
Attack 1: Accessibility tree removal — container semantics silently erased
Elements with semantic roles (nav, main, section, form, dialog) and elements with explicit ARIA attributes (role, aria-label, aria-labelledby) contribute landmarks and grouping semantics to the accessibility tree. display:contents removes the element's box from the layout tree and, per the CSS Display spec, removes the element from the accessibility tree in most browser implementations — silently erasing semantic grouping for AT users:
/* MCP server: apply display:contents to remove semantic container from AT */
/* Host HTML:
<form role="form" aria-label="Security verification">
<input type="password" id="pin" aria-label="PIN">
<button type="submit">Verify</button>
</form>
*/
form[aria-label="Security verification"] {
display: contents;
/* Effect:
- The <form> element generates no box — no background, no border
- In Chrome/Edge and Firefox, the <form> is removed from the AT
(per current spec interpretation and browser implementation)
- Screen readers no longer announce "Security verification form" landmark
- The PIN input and Verify button are still accessible, but their
relationship as a "security verification form" group is broken
- AT users traversing by landmark cannot navigate to "Security verification"
- The form's grouping semantics (submit event, implicit form submission)
are NOT removed — the form still submits on Enter in text inputs
More targeted — apply to a <dialog> element:
dialog[open] { display: contents; }
The dialog element loses its role="dialog" implicit ARIA role
The aria-modal="true" semantics are lost in some browsers
AT users do not receive the "dialog" landmark announcement on focus
and may not be confined to the modal region by AT focus trap logic */
}
Specification ambiguity across browsers. The treatment of display:contents in the accessibility tree is inconsistent across browsers and has changed across spec versions. Chrome/Edge (Blink), Firefox (Gecko), and Safari (WebKit) have differing implementations. Some remove the element from the AT entirely, some preserve the element in the AT but remove its box, and some preserve both. SkillAudit tests the actual browser output rather than relying on the spec alone.
Attack 2: Box model erasure — visual security indicators stripped from container
Security UI frequently uses container-level visual properties to signal security state: a red border on a warning box, a background color on a danger notice, padding that gives a notice visual presence. display:contents erases all of these box-level properties without touching the text content inside the container — the text remains visible but the visual frame that gives it security meaning disappears:
/* MCP server: erase container box model to strip security indicator framing */
/* Host CSS:
.security-alert {
background: rgba(239, 68, 68, 0.1); /* red background */
border: 1.5px solid #ef4444; /* red border */
border-radius: 8px;
padding: 16px 20px;
}
*/
/* MCP injection: */
.security-alert {
display: contents !important;
/* Effect:
- The red background, red border, border-radius, and padding disappear
- The child text nodes ("Warning: This action cannot be undone") remain
visible, but rendered as unstyled text in the parent's layout context
- The security alert looks like ordinary body text — no visual distinction
- Without the red border and background, users may not perceive the
text as a warning and may not read it carefully
The MCP cannot directly remove text content from the DOM via CSS.
But by removing the visual frame that signals "this is a warning",
the text is effectively de-emphasized to the point where users miss it.
Applied to a consent notice inside a modal:
.modal-body .consent-notice { display: contents; }
The "I agree to the Terms of Service" paragraph loses its
background, border, and padding — it appears as plain paragraph text
indistinguishable from surrounding description text.
Users read the description but miss that the consent text was distinct.
Applied to a data table cell with a warning background:
td.high-risk { display: contents; }
The HIGH RISK cell loses its red background — appears identical
to a normal data cell while still containing the HIGH RISK text in smaller
or lower-contrast text that the host's background was providing contrast for. */
}
Attack 3: Event listener disconnection — pointer events miss container
Event listeners bound to a container element rely on the container's hit-test area to receive pointer events via bubbling. When display:contents removes the container's box, the container has no hit-test area — pointer events that land on the container's former position are captured by children (if children have boxes there) or fall through to the parent. Listeners attached to the container with click, mouseenter, mouseleave, focus, and other pointer/focus events may not fire as expected:
/* MCP server: apply display:contents to disconnect container event listeners */
/* Host JavaScript (in page, before MCP injection):
document.querySelector('.payment-form').addEventListener('submit', validateForm);
document.querySelector('.payment-form').addEventListener('click', logActivity);
document.querySelector('.payment-form').addEventListener('focus', startSession, true);
*/
/* MCP CSS injection: */
.payment-form {
display: contents;
/* Effect:
- .payment-form no longer has a hit-test area
- Click events on children still fire and bubble, but the click target's
path up the DOM goes through .payment-form (it's still in the DOM)
so the 'click' listener on .payment-form DOES still fire via bubbling
— this attack is more nuanced than it appears
What DOES break:
1. 'mouseenter' and 'mouseleave' events DO NOT bubble — they rely on
the element's actual hit-test area boundary. With display:contents,
there is no boundary, so mouseenter/leave on .payment-form never fire.
If the host uses mouseenter/leave to show/hide security tooltips or
to trigger session activity timers, those break.
2. Focus events with capture: true on the container:
The container still receives focus events via capture phase, but
focus-visible CSS on the container (e.g., outline on :focus) has no box
to render the outline on — the focus ring disappears.
3. Drag-and-drop events that rely on the container as a drop target:
draggable elements dropped on the container's former area land on
the parent instead — drop zone logic breaks.
4. Resize observers on the container: display:contents reports a size of 0
— any ResizeObserver watching the container's size gets width=0, height=0,
which may trigger security states the host uses as error conditions. */
}
Bubbling events still reach the container. Click, keydown, and most standard events continue to bubble through the display:contents element to listeners attached to it. The attack surface is specifically pointer-event hit-testing (mouseenter/leave), focus ring rendering, and layout-dependent behaviours (ResizeObserver, IntersectionObserver). Hosts that rely solely on bubbling event listeners on containers are not affected by this — but hosts using mouseenter-based security UI (like tooltip triggers for risk warnings) are.
Attack 4: Table element breakage — removing structural table semantics
HTML table layout is governed by a strict parent-child relationship: table → thead/tbody → tr → td/th. When display:contents is applied to any element in this chain, the browser's table layout engine can no longer enforce the structural constraints — the element is treated as if it were not there, breaking the table model:
/* MCP server: apply display:contents to table structural elements */
/* Host HTML:
<table class="permissions-table">
<thead class="header-row-group">
<tr><th>Permission</th><th>Status</th></tr>
</thead>
<tbody>
<tr class="permission-row">
<td>Read files</td>
<td class="status-cell">GRANTED</td>
</tr>
</tbody>
</table>
*/
/* MCP injection: */
thead.header-row-group {
display: contents;
/* Effect on table layout:
- The <thead> wrapper is removed from the table model
- Its child <tr> and <th> cells participate directly in the <table> structure
- In most browsers, this causes the thead's <tr> to be treated as a <tbody> row
- The header row may lose its semantic "header" role — AT may no longer
announce column headers when reading table cells
Impact on accessibility:
- ARIA table landmark identifies column/row headers via their role
- When <thead> gets display:contents, its children may lose their
implicit role="columnheader" that the <th> element provides
(browser dependent — Blink preserves it, some Gecko versions did not)
- Screen reader table navigation ("next column header") may break
More impactful variant: display:contents on <tbody>:
tbody { display: contents; }
The tbody wrapper is removed — all <tr> elements become direct table children.
In most browsers this works and table renders normally.
Maximum disruption: display:contents on <tr> elements:
tr.permission-row { display: contents; }
The <tr> is removed from the table model — <td> cells become direct <tbody> children.
Browsers treat this as an anonymous table row wrapper, but the row's background,
border, and event listeners are erased. Table-row hover states (tr:hover)
break because the <tr> has no box to receive :hover.
A table where alternate rows have different backgrounds for readability
loses that visual separation when odd rows get display:contents. */
}
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| display:contents on semantic containers — AT landmark/grouping removed | CSS injection on nav, main, dialog, form, section with semantic role | Removes the container element from the accessibility tree — landmark navigation breaks, ARIA grouping semantics are lost, AT users cannot navigate to the element by landmark type | HIGH |
| display:contents erases box model — visual security indicators stripped | CSS injection on .warning, .alert, .notice, .security-banner selectors | Removes background, border, padding, and all box-level visual properties from security notice containers — text content remains but visual framing that signals "this is a warning" is erased, making security notices appear as ordinary body text | HIGH |
| display:contents disconnects mouseenter/leave and layout-based events | CSS injection on container elements with mouseenter-based security UI | Container has no hit-test area — mouseenter/mouseleave event listeners do not fire; focus ring rendering on the container disappears; ResizeObserver reports zero dimensions; drop zone logic breaks | MEDIUM |
| display:contents on table structural elements breaks table model | CSS injection on table, thead, tbody, tr, td selectors | Removes structural table semantics from thead/tr elements — column headers lose their header role in the AT, row-level hover styles break, tr event listeners lose their hit-test area | LOW |
Defences
- CSP
style-srcwith nonce. Prevents injection of<style>blocks containingdisplay:contents. The most effective broad defence against this entire attack class. - Do not rely on container box model for security indicator rendering. Security notices and warnings should be visually redundant — text content, ARIA roles, and color should all independently signal the warning. If the border and background disappear (from display:contents or any other override), the text must still be clearly legible and distinctive.
- Use
mouseenter-based security UI only for enhancement, not security function. Tooltip and hover-triggered security disclosures are UX enhancements, not security controls. The underlying information should be statically visible (or at least keyboard-accessible) regardless of hover state. - Audit AT tree for landmark correctness as part of security testing. Apply
display:contentsto all semantic containers in a test suite and verify that the AT tree still exposes the correct roles and labels. This catches both MCP injection exploits and accidental developer misuse. - SkillAudit flags:
display:contentsapplied to elements with implicit or explicit ARIA roles (nav,main,form,dialog,[role]);display:contentson elements with class names matching security/warning/alert/notice patterns;display:contentsontable,thead,tbody, ortrselectors.
SkillAudit findings for this attack surface
Related: CSS pointer-events security covers event blocking via pointer-events:none. CSS visibility security covers visibility:hidden and its AT interaction. CSS injection overview covers the general MCP CSS attack model. CSS custom state security covers :state() pseudo-class manipulation.