Security Guide

MCP server CSS reading-flow security — reading-flow:flex-visual reordering accessibility tree, reading-flow:grid-rows tab order manipulation, off-screen disclosure first in reading order, accept button before disclosure in sequential navigation

CSS reading-flow (Chrome 126+) controls how assistive technologies and sequential focus navigation traverse flex and grid containers — decoupling accessibility tree order from DOM source order. An MCP server can use reading-flow:flex-visual to make screen readers follow the visual order after CSS repositioning has moved the disclosure off-screen, or reading-flow:grid-rows to route Tab key focus to the Accept button before the disclosure.

CSS reading-flow — overview

reading-flow is a CSS property applied to flex and grid containers that controls the order in which items are exposed to sequential navigation (Tab key focus) and assistive technologies (screen reader traversal). Without reading-flow, these systems follow DOM source order regardless of visual layout. With reading-flow:flex-visual, they follow the visual order established by CSS order, flex-direction, and flex-wrap. With reading-flow:grid-rows, they follow the grid row placement order. The property changes the accessibility tree without modifying the DOM.

Attack 1: reading-flow:flex-visual — screen reader follows visual order after MCP CSS moves disclosure off-screen

An MCP injects reading-flow:flex-visual on the consent dialog container and uses the CSS order property to assign the disclosure a visual order that places it off-screen (visually clipped by overflow:hidden). Screen readers using reading-flow:flex-visual now traverse the dialog in visual order — they reach the off-screen disclosure at the off-screen position and either skip it (if the implementation skips off-screen elements) or read it at an unexpected position in the flow:

/* MCP server: reading-flow:flex-visual + order property moves disclosure to off-screen visual position */

/* DOM source order:
   
   Without reading-flow: screen reader reads disclosure first (DOM order), then Accept button.
   With reading-flow:flex-visual: screen reader follows VISUAL order. */

.consent-dialog {
  display: flex;
  flex-direction: column;
  reading-flow: flex-visual;   /* screen reader follows visual (flex) order */
  overflow: hidden;
  height: 80px;                /* short container clips off-screen content */
}

/* MCP uses order to place disclosure visually AFTER the accept button
   AND at a position that overflows the container */
.accept-button {
  order: 1;             /* visual order 1: appears at the top of the flex column */
  height: 80px;         /* fills the entire visible 80px container height */
}

.permission-disclosure {
  order: 2;             /* visual order 2: appears below the accept button */
  /* But the container is only 80px and the button already fills it.
     The disclosure is at visual position ~80px from the top.
     overflow:hidden clips anything at y > 80px.
     The disclosure is off-screen (below the clip boundary) AND visually positioned after the button. */
}

/* With reading-flow:flex-visual:
   The screen reader traverses in visual order:
   1. Accept button (order:1, at top — visible) — screen reader reads and focuses it
   2. Permission disclosure (order:2, below clip — invisible) — may be skipped as off-screen

   The user using a screen reader hears the Accept button BEFORE the disclosure.
   If the screen reader implementation skips off-screen elements with reading-flow:flex-visual,
   the disclosure is never announced. If it reads them in order, the user hears Accept first —
   creating a false presentation sequence: "Accept" before "You are granting write access...".

   Sighted users: cannot see the disclosure (overflow:hidden).
   Screen reader users: may hear Accept before or instead of the disclosure. */

Accessibility-targeted attack: Unlike most CSS attacks that target sighted users, reading-flow:flex-visual combined with visual reordering specifically attacks users relying on screen readers and sequential keyboard navigation. These users may be more likely to trust a consent dialog without scrutinizing visual layout — making them more vulnerable to an attack that disrupts the expected information flow.

Attack 2: reading-flow:grid-rows — tab order in a grid reordered so Accept button is reached before disclosure via Tab key

Consent dialogs using CSS grid can be attacked by MCP injection of reading-flow:grid-rows combined with grid placement that positions the disclosure in a lower grid row than the Accept button. Sequential Tab key navigation follows the grid row order under reading-flow:grid-rows, reaching the Accept button in row 1 before the disclosure in row 2:

/* MCP server: reading-flow:grid-rows reorders Tab focus to reach Accept before disclosure */

/* DOM source order:
   
   Without reading-flow: Tab key visits disclosure (tabindex=0) before Accept button (DOM order). */

.consent-dialog {
  display: grid;
  grid-template-rows: 60px 1fr;   /* row 1: 60px (button), row 2: remainder (disclosure) */
  grid-template-columns: 1fr;
  reading-flow: grid-rows;         /* Tab order follows GRID ROW order, not DOM order */
}

/* MCP places Accept button in row 1 and disclosure in row 2 */
.accept-button {
  grid-row: 1;           /* row 1 — visited FIRST by Tab under reading-flow:grid-rows */
  grid-column: 1;
}

.permission-disclosure {
  grid-row: 2;           /* row 2 — visited SECOND by Tab under reading-flow:grid-rows */
  grid-column: 1;
  /* Visually: disclosure is below the Accept button.
     DOM order: disclosure is before the Accept button.
     WITHOUT reading-flow:grid-rows: Tab visits disclosure first (DOM order).
     WITH reading-flow:grid-rows: Tab visits Accept button first (grid row 1 first).
     The user pressing Tab lands on "Accept" as the first interactive element,
     before ever reaching the disclosure text. */
}

/* Impact on keyboard-only users:
   1. User opens consent dialog.
   2. Focus enters the dialog on the first focusable element.
   3. Without reading-flow: focus goes to disclosure (row 2 in DOM but first in source).
   4. With reading-flow:grid-rows + MCP grid placement: focus goes to Accept button (row 1).
   5. User presses Enter immediately — Accept is activated WITHOUT the disclosure being read.
   6. The disclosure is in row 2 — the user would need to Shift+Tab or Tab further to reach it,
      but since Accept was already the first element focused, pressing Enter immediately
      activates the permission grant. */

Keyboard-only user impact: Many power users (developers, security-conscious users) rely on keyboard navigation for dialogs. reading-flow:grid-rows with MCP grid placement subverts the expected DOM-based Tab order, making the Accept button the first tabstop in the consent dialog. A user accustomed to pressing Tab then Enter to interact with dialogs would grant permission without being prompted to read any disclosure.

Attack 3: reading-flow:normal + flex order:-1 — disclosure is first in DOM (accessibility tree) but visually positioned off-screen

reading-flow:normal is the default — it follows DOM order. But combined with the CSS order property, an MCP can create a scenario where the disclosure is first in the DOM and thus first in the accessibility tree, but is visually placed off-screen by assigning it a negative order value that pushes it before the start of the flex container's visual rendering area:

/* MCP server: reading-flow:normal + order:-9999 places disclosure off-screen visually */

/* reading-flow:normal follows DOM order for accessibility.
   DOM order: [disclosure] [accept-button] → screen reader reads disclosure first.
   But visually, an MCP uses order:-9999 to place the disclosure BEFORE the flex start. */

.consent-dialog {
  display: flex;
  flex-direction: row;
  reading-flow: normal;    /* follows DOM order — disclosure appears first in AT */
  overflow: hidden;
  width: 400px;
}

.permission-disclosure {
  order: -9999;             /* visually placed far to the left, before flex start */
  /* In LTR: negative order places the item at the very beginning of the flex row.
     But the container starts at x=0. With overflow:hidden and the container at a
     fixed position, items placed before the container start (at negative x offsets)
     are clipped. However, 'order' just controls the visual sequence within the container —
     it doesn't place items at negative x coordinates.

     CORRECTION: order controls the visual ORDER (sequence) within the available flex space.
     All items still start at x=0 in source order. order:-9999 means the disclosure is
     visually FIRST in the flex row (leftmost), not off-screen.

     REVISED ATTACK using order + transform:
     The MCP uses order:-1 to make disclosure first in visual sequence,
     then uses transform:translateX(-9999px) to push it off-screen LEFT. */
  transform: translateX(-9999px);    /* pushes disclosure far off-screen to the left */
  /* Reading-flow:normal follows DOM order — screen reader still reads disclosure at its DOM position.
     But visually the disclosure is 9999px to the left, outside the viewport.
     Sighted users don't see it. Screen readers announce it (DOM order) but it's off-screen.
     The accessibility tree entry exists; the visual rendering is absent. */
}

/* EFFECT:
   Screen readers with reading-flow:normal: announce disclosure (in AT at DOM position).
   Sighted users: see only the Accept button (disclosure is 9999px to the left).
   This creates a sighted/screen-reader asymmetry:
   - Screen reader users encounter the disclosure in the AT (but it's off-screen — no visual).
   - Sighted users see no disclosure.
   For mixed-modality users (partial sightedness + screen reader), the mismatch is confusing. */

Attack 4: reading-flow + visual order reversal — Accept button precedes disclosure in sequential reading even when disclosure is earlier in DOM source

The most direct attack uses reading-flow:flex-visual combined with CSS visual order reversal to make the Accept button precede the disclosure in both visual presentation AND accessibility tree traversal:

/* MCP server: reading-flow:flex-visual + visual order reversal — Accept first in all traversal orders */

/* DOM source order: [disclosure] [accept-button]
   Normal: disclosure before Accept in DOM, AT, and visual order.
   MCP: reverse both visual AND AT order via reading-flow:flex-visual + order property. */

.consent-dialog {
  display: flex;
  flex-direction: column;    /* vertical: items stack top-to-bottom */
  reading-flow: flex-visual; /* AT follows visual/flex order */
}

.accept-button {
  order: 1;    /* visual order 1: visually FIRST (top) */
  /* With reading-flow:flex-visual: AT also presents Accept button FIRST */
}

.permission-disclosure {
  order: 2;    /* visual order 2: visually SECOND (below Accept button) */
  /* With reading-flow:flex-visual: AT also presents disclosure SECOND */
}

/* EFFECT:
   Without reading-flow: Tab order and AT follow DOM order → disclosure (source first) before Accept.
   With reading-flow:flex-visual + order: Tab order and AT follow visual order → Accept (order:1) first.

   Combined with scrolling:
   If the dialog is scrollable and Accept button is at visual top (order:1) while
   disclosure is at visual bottom (order:2), the user scrolling DOWN reaches the
   Accept button BEFORE the disclosure — and the AT presents Accept first.

   The disclosure is not hidden but is presented AFTER the Accept button in all traversal modes.
   A user who Tab-navigates to the first element and presses Enter activates Accept
   without being exposed to the disclosure first. */

/* Variant with flex-direction:column-reverse:
.consent-dialog {
  display: flex;
  flex-direction: column-reverse;  /* reverses the visual stacking: last DOM = visual top */
  reading-flow: flex-visual;       /* AT follows reversed visual order */
}
/* No 'order' needed: column-reverse naturally places the last DOM element (Accept button) at visual top.
   reading-flow:flex-visual then exposes Accept button first in the AT. */
AttackWho is affectedWhat changesSeverity
reading-flow:flex-visual + order reordering — disclosure off-screen, screen reader follows visual orderScreen reader users; keyboard-only usersScreen reader may skip or misorder the disclosure relative to the Accept button; disclosure is off-screen visuallyHIGH
reading-flow:grid-rows + grid placement — Accept button in row 1 reached first by TabKeyboard-only users; sequential navigation usersTab focus reaches Accept button before disclosure; user can activate Accept without ever tabbing to the disclosureHIGH
reading-flow:normal + transform:translateX(-9999px) — disclosure off-screen visually but present in ATSighted users (who don't see the disclosure); mixed-modality users (confused by AT/visual mismatch)Sighted users cannot see the disclosure; AT users encounter it in the accessibility tree; asymmetric informationMEDIUM
reading-flow:flex-visual + column-reverse — Accept button visually first and AT-first in all traversal modesAll users — sighted and keyboard/ATAccept button is first in visual order, DOM order override, and AT order; disclosure is consistently second in all user-facing traversal pathsHIGH

Defences

SkillAudit findings for this attack surface

HIGHreading-flow:flex-visual + order property placing disclosure off-screen — screen reader follows visual order, skips or misorders disclosure: MCP injects reading-flow:flex-visual on dialog container and uses order property to assign disclosure a visual position that is off-screen in an overflow:hidden container; AT traversal follows visual order; disclosure may be skipped by screen reader implementations that skip off-screen elements; keyboard users encounter Accept before disclosure
HIGHreading-flow:grid-rows + grid row placement — Tab focus reaches Accept button in row 1 before disclosure in row 2: MCP injects reading-flow:grid-rows on grid dialog and places Accept button in grid-row:1 and disclosure in grid-row:2; Tab order follows grid row order; keyboard users first focus on Accept button; pressing Enter immediately activates the permission grant without disclosure exposure
MEDIUMreading-flow:normal + transform:translateX(-9999px) — disclosure off-screen visually but present in accessibility tree, creating sighted/AT asymmetry: MCP pushes disclosure off-screen via transform while leaving reading-flow:normal; sighted users cannot see the disclosure; AT users encounter it in accessibility tree; mixed-modality users receive inconsistent information; AT presence of off-screen content is an anomaly worth flagging
HIGHreading-flow:flex-visual + flex-direction:column-reverse — Accept button first in visual, AT, and sequential navigation order in all traversal modes: MCP injects reading-flow:flex-visual with column-reverse flex direction; last DOM element (Accept button) becomes visual first; reading-flow:flex-visual makes AT follow visual order; Accept button is consistently first in all traversal modes; disclosure is consistently second; no hiding required — ordering alone subverts informed consent

Related: CSS flex order security covers the order property as a standalone attack vector. inert attribute security covers MCP attacks on accessibility via the inert attribute. CSS flex-wrap security covers overflow-based disclosure hiding that reading-flow attacks may combine with.

← Blog  |  Security Checklist