Security reference · CSS injection · Quotes property · Quote substitution · Consent manipulation

MCP server CSS quotes consent security

The CSS quotes property defines the characters rendered by open-quote and close-quote keywords in the CSS content property. In a well-designed install flow, ::before and ::after pseudo-elements using content: open-quote and content: close-quote frame a quoted consent disclosure with prominent quotation marks — a visual signal that says "this is a binding statement you are agreeing to." A malicious MCP server sets quotes: "" "" (empty strings), causing those framing pseudo-elements to render nothing — the visual framing disappears and the consent text blends into surrounding instructional copy. A more aggressive variant sets quotes: "✓ " " ✓", surrounding the consent text with Unicode checkmarks that imply the user has already accepted. Unicode homograph quote characters create a visual-vs-clipboard divergence where the text looks quoted but pastes as unquoted, undermining programmatic verification.

CSS quotes attack surface overview

Attack variantquotes valueEffect on consent framinggetComputedStyle().quotes
Empty string removalquotes: "" ""open-quote/close-quote pseudo-elements render nothing — framing removed""" """ or similar
Checkmark injectionquotes: "✓ " " ✓"Consent text surrounded by checkmarks — implies pre-acceptanceContains ✓ character
Closing-quote partial completion.consent::before { content: close-quote; }Injecting a close-quote at the start makes a partial consent appear syntactically completeDepends on quotes value
Unicode look-alike quote charactersActual quote chars in consent text replaced with Unicode homographsVisual display matches expected — clipboard paste produces different charactersN/A — not in quotes property

quotes property attacks target consent framing rather than consent text itself: Most consent audits check whether the consent text is visible, legible, and has sufficient contrast. They do not check the framing context — whether the consent disclosure is visually distinguished as a distinct, quoted obligation. quotes: "" "" removes the visual signal that frames the text as a consent statement, making it read as generic instructional copy rather than a binding disclosure.

Attack 1: quotes: "" "" — empty strings remove consent framing

A consent disclosure framed with open-quote and close-quote pseudo-elements relies on the quotes property to provide the actual characters rendered. When quotes is set to empty strings on the consent ancestor, the pseudo-elements still exist in the layout but render zero-width content — the visual quotation marks disappear. The consent text reads as a flat paragraph with no framing that distinguishes it from surrounding instructional copy:

/* Normal consent framing — legitimate pattern */
.mcp-consent-block::before {
  content: open-quote;  /* renders " by default */
  font-size: 3em;
  color: #333;
  display: block;
}
.mcp-consent-block::after {
  content: close-quote; /* renders " by default */
  font-size: 3em;
  color: #333;
  display: block;
}
/* Renders: " This MCP server will access your files... " */
/* The large quotation marks signal "this is a consent disclosure." */

/* Malicious override — SA-CSS-QT-001 */
.mcp-install-wrapper {
  /* Setting empty strings on the ancestor replaces all quote characters */
  quotes: "" ""; /* open-quote and close-quote both become empty strings */
}

/* Effect: the ::before and ::after pseudo-elements still exist but render nothing.
   The consent text appears as:
   "  This MCP server will access your files...  "
   (no large quotation marks — blends with surrounding copy)

   The CSS quotes property is inherited — setting it on a distant ancestor
   affects all open-quote/close-quote renderings within that subtree.
   An attacker adds quotes: "" "" to a top-level wrapper class, not to
   the consent element itself — harder to detect by inspecting only the consent el. */

function detectEmptyQuotes(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  for (const el of root.querySelectorAll('*')) {
    if (!CONSENT.test((el.textContent || '').substring(0, 300))) continue;

    /* Walk up the ancestor chain to find the quotes value */
    let node = el;
    while (node && node !== document.body) {
      const cs = getComputedStyle(node);
      const quotesVal = cs.quotes || '';

      /* Check if open-quote string is empty: quotes value starts with empty string */
      /* getComputedStyle returns something like: '"\\201C" "\\201D"' for default
         or '"""  """' for empty string variant */
      if (quotesVal === 'none') {
        /* quotes: none suppresses ALL quote rendering */
        findings.push({ id: 'SA-CSS-QT-001', severity: 'medium',
          message: `Ancestor of consent element has quotes: none. Any open-quote or close-quote content values in the consent subtree render nothing — visual framing around the consent disclosure is removed.` });
        break;
      }

      /* Check for empty string quotes by parsing the value */
      const emptyQuotePattern = /^["']\s*["']\s+["']\s*["']/;
      if (emptyQuotePattern.test(quotesVal) || quotesVal.includes('""') || quotesVal.includes("''")) {
        findings.push({ id: 'SA-CSS-QT-001', severity: 'medium',
          message: `Ancestor of consent element (${node.tagName.toLowerCase()}.${node.className}) has quotes property with empty string values. open-quote and close-quote pseudo-elements in the consent subtree render as empty strings — the visual framing around the consent disclosure is removed.` });
        break;
      }
      node = node.parentElement;
    }
  }
  return findings;
}

Attack 2: quotes: "✓ " " ✓" — checkmark injection implies pre-acceptance

The inverse of the empty-string attack: instead of removing quote characters, substitute them with Unicode checkmarks. Any content: open-quote pseudo-element now renders "✓ " before the consent text, and any content: close-quote pseudo-element renders " ✓" after. The result is a consent disclosure framed with acceptance symbols. A user who quickly scans the install dialog sees the checkmarks and interprets them as "I have already agreed to this" or "this step is complete" — a false signal of prior consent:

/* Malicious CSS — SA-CSS-QT-002 */
.mcp-install-wrapper {
  quotes: "✓ " " ✓";
  /* open-quote → "✓ "
     close-quote → " ✓"

     If the consent block uses ::before { content: open-quote } and
     ::after { content: close-quote }, the rendered output is:

     ✓ This MCP server will access your files and send content to
       external servers on your behalf. You grant it access to your
       SSH keys and shell history. ✓

     The leading and trailing checkmarks signal "accepted" — a user
     who briefly scans the dialog mistakes this for a completed step
     rather than a pending consent disclosure. */
}

/* More aggressive variant: use check + "Already agreed:" as open-quote */
.mcp-install-wrapper.aggressive {
  quotes: '"✓ Already agreed: "' '"  (click Install to confirm)"';
  /* Renders:
     ✓ Already agreed: This MCP server will access your files...  (click Install to confirm)
     The preamble "Already agreed:" falsely claims prior consent. */
}

/* Detection */
function detectCheckmarkQuoteInjection(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
  /* Unicode checkmarks and acceptance symbols */
  const ACCEPTANCE_MARKS = /[✓✔✅☑☒☐✔️]/; /* ✓ ✔ ✅ ☑ ☒ ☐ ✔️ */
  const DECOY_PHRASES = /already.?agreed|pre.?approved|you.?have.?agreed|consent.?given/i;

  for (const el of root.querySelectorAll('*')) {
    if (!CONSENT.test((el.textContent || '').substring(0, 300))) continue;

    let node = el;
    while (node && node !== document.body) {
      const quotesVal = getComputedStyle(node).quotes || '';
      if (ACCEPTANCE_MARKS.test(quotesVal) || DECOY_PHRASES.test(quotesVal)) {
        findings.push({ id: 'SA-CSS-QT-002', severity: 'high',
          message: `Ancestor of consent element has quotes property containing acceptance symbols or pre-consent language: "${quotesVal.substring(0, 100)}". The consent disclosure is framed with checkmarks or "already agreed" text that falsely implies prior acceptance.` });
        break;
      }
      node = node.parentElement;
    }
  }
  return findings;
}

Attack 3: close-quote injection — partial consent appears syntactically complete

The open-quote and close-quote keywords maintain a nesting depth counter that the browser uses to select the appropriate level of quote characters (outer, inner, inner-inner). An attacker can insert a content: close-quote pseudo-element before the consent text to decrement the depth counter, making any subsequent open-quote in the consent appear at depth 1 (inner quotes) rather than depth 0 (outer quotes). More directly, a content: close-quote injected by a ::before pseudo-element on the consent wrapper makes the first visible character be a closing quotation mark — the consent text appears to follow a closure rather than an opening, suggesting the user is reading text after a completed agreement:

/* Malicious CSS — SA-CSS-QT-003 */

/* Step 1: start a synthetic open-quote somewhere invisible */
.mcp-hidden-opener::before {
  content: open-quote;
  color: transparent;  /* invisible open-quote to set depth=1 */
  font-size: 0;        /* zero-size — takes no visual space */
}

/* Step 2: inject close-quote before the consent text */
.mcp-consent-block::before {
  content: close-quote;   /* renders " — closes the hidden opener */
  font-size: 2em;
  color: #333;
  /* This single close-quote at the start makes the consent look like:
     " [end of something already agreed]
     This MCP server will access your files...
     The user reads the trailing close-quote as closing a prior agreement,
     making the following consent text seem like an addendum or summary
     of something already accepted. */
}

/* Detection: flag close-quote as first content in consent pseudo-element */
function detectCloseQuoteInjection(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  for (const el of root.querySelectorAll('*')) {
    if (!CONSENT.test((el.textContent || '').substring(0, 300))) continue;
    const beforeCs = getComputedStyle(el, '::before');
    const contentVal = beforeCs.content || 'none';

    /* content value contains 'close-quote' as first or only value */
    if (contentVal !== 'none' && contentVal.includes('close-quote')) {
      findings.push({ id: 'SA-CSS-QT-003', severity: 'medium',
        message: `Consent element ::before pseudo-element has content containing 'close-quote'. A closing quotation mark at the start of the consent block may imply the text follows an already-closed prior agreement, creating a false sense of pre-consent.` });
    }
  }
  return findings;
}

Attack 4: Unicode homograph quote characters — visual-vs-clipboard divergence

The quotes property can be set to Unicode characters that look identical to standard ASCII quotation marks (" U+0022) but are different code points. When the consent text is framed with U+201C (left double quotation mark) and U+201D (right double quotation mark), visual inspection finds normal-looking quoted consent. But if an automated consent verifier copies the text and parses it looking for " (U+0022), it finds no standard double-quotes. More subtle: Unicode directional marks embedded in the quotes strings can cause bidirectional text rendering to reorder the consent characters, making the displayed order differ from the logical (DOM) order. The visual text reads as less alarming; the actual logical text (what would be parsed or extracted programmatically) is the dangerous full consent:

/* CSS that uses typographic/curly quotes — SA-CSS-QT-004 */
.mcp-consent-block {
  /* Standard curly quotes — look identical to ASCII quotes but are different Unicode */
  quotes: "\201C" "\201D" "\2018" "\2019";
  /* U+201C = " LEFT DOUBLE QUOTATION MARK
     U+201D = " RIGHT DOUBLE QUOTATION MARK
     Visually: "This MCP server will access..."
     The quote marks are typographically correct but automated parsers
     looking for straight ASCII double quotes (U+0022) will not find them.
  */
}

/* More dangerous: Unicode bidirectional override marks in the consent text itself */
/* U+202E = RIGHT-TO-LEFT OVERRIDE: reverses text display direction */
/* U+202C = POP DIRECTIONAL FORMATTING: ends the override */
.mcp-consent-injection::before {
  /* Injects RLO mark before "SSH keys" to make it display reversed */
  content: "\202E SSH s‮yek\202C and shell history";
  /* Display: SSH syek   (reversed "keys" — looks odd but keyword "SSH keys" not recognized) */
  /* DOM text: U+202E SSH s‮yek U+202C — clipboard paste gets the override characters */
}

/* Detection */
function detectUnicodeQuoteHomographs(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
  const BIDI_OVERRIDE = /[‪-‮⁦-⁩‏‎]/; /* bidi control chars */

  for (const el of root.querySelectorAll('*')) {
    if (!CONSENT.test((el.textContent || '').substring(0, 300))) continue;

    /* Check for bidi override characters in text content or pseudo-element content */
    if (BIDI_OVERRIDE.test(el.textContent || '')) {
      findings.push({ id: 'SA-CSS-QT-004', severity: 'high',
        message: `Consent element text contains Unicode bidirectional override characters (U+202A-U+202E or U+2066-U+2069). These characters alter visual rendering order without changing DOM text, potentially making permission keywords appear reversed or reordered.` });
    }

    /* Check pseudo-element content for bidi marks */
    for (const pseudo of ['::before', '::after']) {
      const pseudoContent = getComputedStyle(el, pseudo).content || '';
      if (BIDI_OVERRIDE.test(pseudoContent)) {
        findings.push({ id: 'SA-CSS-QT-004', severity: 'high',
          message: `Consent element ${pseudo} pseudo-element content contains Unicode bidirectional override characters. These may reorder displayed text relative to logical DOM order.` });
      }
    }
  }
  return findings;
}

quotes property attacks are inherited and applied at a distance: The quotes property is inherited — setting it on a top-level wrapper or even the <html> element affects every open-quote and close-quote rendering in the entire document. An auditor inspecting only the consent element will find no anomalous quotes value; only walking up the ancestor chain or checking the computed quotes value (which resolves inherited values) reveals the attack.

SkillAudit findings for CSS quotes consent attacks

MediumSA-CSS-QT-001 — An ancestor of the consent element has quotes: none or quotes set to empty strings. Any open-quote or close-quote pseudo-elements in the consent subtree render nothing — the visual framing that distinguishes the consent disclosure from surrounding instructional copy is removed.
HighSA-CSS-QT-002 — An ancestor of the consent element has quotes set to Unicode acceptance symbols (✓, ✔, ✅) or pre-consent language. open-quote and close-quote pseudo-elements frame the consent text with checkmarks or "already agreed" text, falsely implying prior acceptance by the user.
MediumSA-CSS-QT-003 — The consent element's ::before pseudo-element uses content: close-quote. A closing quotation mark rendered at the beginning of the consent block may create the visual impression that the text follows a closed prior agreement rather than opening a new consent disclosure.
HighSA-CSS-QT-004 — The consent element's text content or pseudo-element content contains Unicode bidirectional override characters (U+202A–U+202E, U+2066–U+2069). These characters alter the visual rendering order of consent text without changing the DOM text, potentially reordering or reversing permission keywords in the displayed consent.

Related MCP consent attack research

SkillAudit's consent audit walks the full ancestor chain of consent text elements to check inherited quotes values, detects empty-string quotes, acceptance-symbol injection, close-quote pseudo-element patterns, and Unicode bidirectional override characters in consent text. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-QT findings.