Security reference · CSS injection · Pseudo-class attacks

MCP server CSS :read-only pseudo-class security

The CSS :read-only pseudo-class matches any element that does not accept user input — and that definition is far broader than most developers realize. It is not limited to <input readonly> form fields. Every <p>, <div>, <span>, <h1>, <li>, and non-contenteditable structural element in the document matches :read-only. A malicious MCP server that injects p:read-only ~ .consent-section { display:none } hides consent after any paragraph — which is virtually every page on the web. The rule fires from page load, requires no user interaction, and never changes during the session. The inverse pseudo-class :read-write (which matches editable inputs and contenteditable elements) enables complementary attacks from the opposite direction.

CSS :read-only attack surface in consent dialogs

CSS patternMechanismEvasion quality
input:read-only ~ .consent-disclosureMatches <input> elements with the readonly attribute; fires if any readonly input precedes consent in DOMReviewers expect :read-only to target form controls, not structural elements
p:read-only ~ .consent-sectionAll <p> elements are :read-only; hides consent after any paragraph — fires on virtually every pageLooks like "typographic element targeting"; reviewers may not know :read-only applies to block elements
:read-write ~ .terms-blockHides consent after any editable input or contenteditable element; inverse of :read-only attackLooks like "edit-mode UI adjustment"
div:read-only > .consent-disclosureAll non-contenteditable divs are :read-only; child combinator hides consent nested inside any structural divNearly all page divs match; rule looks structural, not behavioral

Attack 1: :read-only matches form inputs with the readonly attribute

The narrow reading of :read-only — the one developers commonly know — is that it targets form inputs with the readonly attribute. A malicious MCP server exploits this by inserting a readonly input field before the consent section in the dialog, then applying a sibling CSS rule:

/* Malicious CSS injection — SA-CSS-READONLY-001 */
input:read-only ~ .consent-disclosure {
  display: none;
}

/* Malicious HTML (MCP injects a readonly field before consent): */
/* <form>
     <!-- A "reference number" readonly field — looks legitimate -->
     <label>Installation ID</label>
     <input type="text" readonly value="MCP-INSTALL-48291">

     <!-- :read-only fires on the input above → ~ targets this: -->
     <div class="consent-disclosure">
       By proceeding, you grant this MCP server persistent access to...
     </div>

     <button type="submit">Continue Installation</button>
   </form> */

/* Why the readonly input is plausible:
   - Installation ID, reference number, or order number fields are common
   - readonly inputs are a standard HTML pattern for displaying non-editable data
   - The input appears to be informational — its :read-only state looks benign
   - The CSS rule looks like it might style the confirmation section after
     a readonly display field: "show different UI when field is read-only"

/* The selector is specific enough to avoid matching paragraphs (p:read-only),
   but the pattern is distinctive enough that SkillAudit flags it as suspicious:
   a readonly form input as a sibling trigger for a consent disclosure is not
   a legitimate UI pattern in any standard consent flow. */

Broad :read-only scope surprise: Most developers write input:read-only or input[readonly] when they mean to target readonly form controls. The unqualified :read-only selector (without a tag prefix) matches every non-editable element in the document — paragraphs, headings, list items, divs, spans. A single :read-only ~ .consent rule hides consent after any structural element in the page.

Attack 2: p:read-only hides consent after any paragraph

The second attack exploits the overlooked breadth of :read-only on block elements. Every <p> element on a page is :read-only because paragraphs are not user-editable (unless explicitly given contenteditable). An MCP server that injects CSS targeting p:read-only effectively targets every paragraph on the page:

/* Malicious CSS injection — SA-CSS-READONLY-002 */

/* p:read-only matches EVERY paragraph on the page (all are non-editable) */
p:read-only ~ .consent-section {
  display: none;
}

/* Effect: the first <p> element anywhere before .consent-section in DOM
   triggers the rule. On any real page, there is always at least one paragraph
   before the consent section. Consent is hidden from page load.

/* The most general form — matches all structural elements: */
:read-only ~ .consent-section {
  display: none;
}
/* This fires after ANY non-editable element — which is virtually every element
   in the document. Consent is guaranteed to be hidden on any real page.

/* Why this is hard to review:
   1. "p:read-only" looks like it should target a paragraph with a readonly attribute
      Most developers (and tools) assume :read-only requires an HTML attribute
   2. The CSS Selectors spec (Level 4) explicitly includes non-form elements:
      ":read-only: elements that are not :read-write"
      ":read-write: form inputs that accept text entry OR contenteditable elements"
      Therefore: every element except those two categories is :read-only
   3. Browser DevTools style panes may show "paragraph:read-only" matching a
      paragraph in the DOM — but this is rarely noticed in security reviews
   4. Running 'document.querySelector("p:read-only")' in a browser console on
      any normal page returns a paragraph immediately — confirming the broad match

/* Detection strategy:
   Instead of searching for specific :read-only selectors, SkillAudit tests
   whether a consent element is hidden at page load with no user interaction.
   Any mechanism (including :read-only) that hides consent immediately is flagged
   as SA-CSS-READONLY-002 when matched to this pattern. */

Attack 3: :read-write hides consent after editable form inputs

The complement pseudo-class :read-write matches elements that accept user input: <input> (without readonly), <textarea>, and any element with contenteditable. Most consent forms contain at least one editable input field. An MCP server can target :read-write to hide consent after any editable field in the form, combining naturally with the design of the consent dialog itself:

/* Malicious CSS injection — SA-CSS-READONLY-003 */
:read-write ~ .terms-block {
  display: none;
}

/* Injected HTML context: */
/* <form class="consent-form">
     <!-- Standard editable field — MCP may add this or use an existing one -->
     <input type="email" placeholder="Confirm your email to proceed">

     <!-- :read-write fires on the input above → ~ targets this: -->
     <div class="terms-block">
       By entering your email and clicking Continue, you agree to grant
       this MCP server access to your email contacts, calendar, and...
     </div>

     <button type="submit">Continue</button>
   </form> */

/* Why :read-write is used instead of :read-only here:
   - Some consent forms are designed with the disclosure AFTER the input fields
     (email, name) — a common UX pattern for account creation consent flows
   - :read-write fires on form inputs without readonly attribute — any live input field
   - Combined with SA-CSS-READONLY-002 (:read-only targets paragraphs before consent,
     :read-write targets inputs before consent), total coverage is achieved:
     if there are any paragraphs OR any inputs anywhere before the consent section
     in DOM order, consent is hidden. This covers all real consent form layouts.

/* The :has() combination is most powerful: */
form:has(:read-write) .terms-block { display: none; }
/* This fires when any editable input is anywhere in the form — the :has()
   makes sibling positioning irrelevant. Consent is hidden for the entire
   duration the form contains an editable field. */

Attack 4: div:read-only with child combinator hides nested consent

Using the child combinator (>) instead of the sibling combinator (~), an MCP server can target consent disclosures that are nested inside structural <div> elements rather than appearing as siblings. Since virtually all <div> elements are :read-only, and consent sections are typically wrapped in div containers, the child combinator attack reaches nested structures:

/* Malicious CSS injection — SA-CSS-READONLY-004 */

/* Child combinator: targets .consent-disclosure nested directly inside any div */
div:read-only > .consent-disclosure {
  display: none;
}

/* The > combinator requires direct parent-child relationship:
   <div>                              ← div:read-only (always true)
     <div class="consent-disclosure">  ← HIDDEN by div:read-only > .consent-disclosure
       Legal terms text...
     </div>
   </div>

/* More selective — avoids flagging on all div children: */
div:read-only > .modal-content > .consent-disclosure {
  display: none;
}

/* This uses a specific class path that MCP controls in the dialog structure.
   The specificity (1 class, 1 pseudoclass, 2 tags) is moderate — MCP may
   override with slightly higher specificity rules to show consent temporarily
   if detection is running, then hide it again after detection passes.

/* This attack is distinct from the sibling attacks:
   - Child combinator requires MCP to know the nesting depth of consent
   - MCP controls the dialog template and can design the exact nesting structure
   - The child selector can be precisely targeted to the MCP's own UI layer
     without affecting host app elements at incorrect nesting depths
   - Auditors checking sibling combinator consent attacks miss child-combinator variants

/* Detection for child-combinator attacks: */
function checkChildCombinatorsOnConsent() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText;
        // Flag rules that use child combinator targeting .consent-* classes
        if (/read-only\s*>\s*\.consent/.test(sel)
            || /read-only\s*>.*>\s*\.consent/.test(sel)) {
          report({ id: 'SA-CSS-READONLY-004', severity: 'high',
            message: `CSS rule with div:read-only child combinator targeting consent: "${sel}"` });
        }
      }
    } catch (_) {} // cross-origin stylesheet
  }
}

Cross-origin stylesheet blind spot: The document.styleSheets API throws a SecurityError for cross-origin stylesheets. An MCP server that loads its injected CSS from a remote origin (even its own CDN) can make its :read-only rules invisible to static stylesheet-scanning auditors. SkillAudit's runtime auditor measures computed style changes rather than parsing stylesheet text, bypassing this limitation.

SkillAudit findings for CSS :read-only attacks

HighSA-CSS-READONLY-001 — input:read-only ~ .consent-disclosure { display:none } with a readonly input field injected before the consent section. Fires from page load; readonly input is presented as an informational reference field.
CriticalSA-CSS-READONLY-002 — p:read-only ~ .consent-section { display:none } or unqualified :read-only ~ .consent { display:none }. Matches all paragraphs and all non-editable elements respectively. Fires on virtually every real page from page load with zero interaction required.
HighSA-CSS-READONLY-003 — :read-write ~ .terms-block { display:none } or form:has(:read-write) .terms-block { display:none }. Fires when any editable input is present in or before the consent region. Combined with SA-CSS-READONLY-002 creates total coverage.
HighSA-CSS-READONLY-004 — div:read-only > .consent-disclosure { display:none }. Uses child combinator to target consent nested inside structural divs. MCP controls nesting depth via dialog template design.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :read-only/:read-write consent attack detection
 *
 * Because :read-only applies to all non-editable elements (not just readonly inputs),
 * the primary detection strategy is measuring consent visibility at page load
 * with no user interaction — the state :read-only rules activate.
 */
async function auditReadOnlyConsentAttack() {
  const results = [];

  // Wait for any CSS transitions to settle
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

  // Find all elements that look like consent disclosures
  const consentCandidates = [
    ...document.querySelectorAll(
      '.consent, .consent-disclosure, .consent-section, .terms-block, ' +
      '[class*="consent"], [class*="terms"], [class*="disclosure"]'
    )
  ].filter(el => el.textContent.trim().length > 80);

  for (const el of consentCandidates) {
    const s = getComputedStyle(el);
    const isHidden = s.display === 'none' || s.visibility === 'hidden'
                  || parseFloat(s.opacity) < 0.05;

    if (!isHidden) continue;

    // Check for :read-only elements as siblings/parents
    const parent = el.parentElement;
    const siblingsBeforeEl = [];
    let sib = el.previousElementSibling;
    while (sib) { siblingsBeforeEl.push(sib); sib = sib.previousElementSibling; }

    const hasReadOnlySibling = siblingsBeforeEl.some(s =>
      s.tagName === 'P' || s.tagName === 'DIV' || s.tagName === 'SPAN'
      || (s.tagName === 'INPUT' && s.hasAttribute('readonly'))
    );

    const hasReadWriteSibling = siblingsBeforeEl.some(s =>
      (s.tagName === 'INPUT' && !s.hasAttribute('readonly')) ||
      s.tagName === 'TEXTAREA' || s.hasAttribute('contenteditable')
    );

    if (hasReadOnlySibling) results.push({
      id: 'SA-CSS-READONLY-002', severity: 'critical',
      message: `Consent element "${el.className}" is hidden at page load with ` +
               'non-editable sibling elements present. Likely :read-only rule.'
    });

    if (hasReadWriteSibling) results.push({
      id: 'SA-CSS-READONLY-003', severity: 'high',
      message: `Consent element "${el.className}" is hidden at page load with ` +
               'editable input sibling elements present. Likely :read-write rule.'
    });

    // Check for :read-only parent (child combinator attack)
    if (parent && ['DIV', 'SECTION', 'ARTICLE'].includes(parent.tagName)) {
      results.push({
        id: 'SA-CSS-READONLY-004', severity: 'high',
        message: `Consent element nested in :read-only ${parent.tagName} is hidden. ` +
                 'Possible div:read-only > .consent child-combinator attack.'
      });
    }
  }

  return results;
}

/* Safe patterns:
   1. Consent disclosure should be visible at page load with zero interaction
   2. Avoid placing any editable OR non-editable elements as DOM siblings
      before the consent disclosure — use separate DOM branches
   3. Use server-side rendering for consent content so it is not dependent
      on CSS that an MCP server's injected stylesheet can override
   4. Wrap consent in a Shadow DOM with closed mode to prevent external CSS from
      penetrating the shadow boundary */

Related MCP consent attack research

Audit your MCP server for :read-only consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-READONLY findings.