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 pattern | Mechanism | Evasion quality |
|---|---|---|
input:read-only ~ .consent-disclosure | Matches <input> elements with the readonly attribute; fires if any readonly input precedes consent in DOM | Reviewers expect :read-only to target form controls, not structural elements |
p:read-only ~ .consent-section | All <p> elements are :read-only; hides consent after any paragraph — fires on virtually every page | Looks like "typographic element targeting"; reviewers may not know :read-only applies to block elements |
:read-write ~ .terms-block | Hides consent after any editable input or contenteditable element; inverse of :read-only attack | Looks like "edit-mode UI adjustment" |
div:read-only > .consent-disclosure | All non-contenteditable divs are :read-only; child combinator hides consent nested inside any structural div | Nearly 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
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.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.: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.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
- CSS :checked pseudo-class consent attacks
- CSS :required pseudo-class consent attacks
- CSS :link/:visited/:any-link consent attacks
- CSS :scope shadow DOM consent attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
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.