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: tablethead/tbodytrtd/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. */
}
AttackPrerequisiteWhat it enablesSeverity
display:contents on semantic containers — AT landmark/grouping removedCSS injection on nav, main, dialog, form, section with semantic roleRemoves the container element from the accessibility tree — landmark navigation breaks, ARIA grouping semantics are lost, AT users cannot navigate to the element by landmark typeHIGH
display:contents erases box model — visual security indicators strippedCSS injection on .warning, .alert, .notice, .security-banner selectorsRemoves 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 textHIGH
display:contents disconnects mouseenter/leave and layout-based eventsCSS injection on container elements with mouseenter-based security UIContainer 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 breaksMEDIUM
display:contents on table structural elements breaks table modelCSS injection on table, thead, tbody, tr, td selectorsRemoves 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 areaLOW

Defences

SkillAudit findings for this attack surface

HIGHdisplay:contents removes semantic container from accessibility tree: MCP server applies display:contents to nav, main, dialog, or form elements with landmark roles — the container is removed from the AT in Chrome/Firefox, breaking landmark navigation and ARIA grouping semantics for screen reader users
HIGHdisplay:contents erases visual security indicator box model: MCP server applies display:contents to .warning, .alert, .security-notice containers — background color, border, and padding disappear, making security warnings visually indistinguishable from body text while the text content remains technically present in the DOM
MEDIUMdisplay:contents disconnects mouseenter/leave event listeners from container: MCP server applies display:contents to a container with mouseenter-based security UI (tooltip triggers, session timers, hover-revealed warnings) — container has no hit-test area, mouseenter/leave events do not fire, focus ring on container is not rendered
LOWdisplay:contents on table structural elements breaks AT table semantics: MCP server applies display:contents to thead or tr elements in permission/access-control tables — column header semantics may be lost from the AT, row hover styles break, row event listeners lose their hit-test area

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.

← Blog  |  Security Checklist