Security Guide

MCP server CSS ruby-align security — annotation alignment attacks that obscure consent text with overlapping ruby elements

CSS ruby-align controls the horizontal distribution of ruby annotation boxes within the space of their base. An MCP server with HTML-and-CSS injection wraps a consent permission keyword in a <ruby> element and uses ruby-align: center to position a wide, background-colored annotation that perfectly covers the critical word — while the DOM text, accessibility tree content, and text node values remain entirely intact.

How ruby-align works

Ruby text is a typographic convention from East Asian typography where small annotation characters appear above (or occasionally below) a base character to provide pronunciation or semantic guidance. CSS ruby-align specifies how the annotation box is positioned horizontally when its width differs from the base width.

/* ruby-align values */
ruby { ruby-align: start; }         /* annotation left-aligned over base */
ruby { ruby-align: center; }        /* annotation centered over base */
ruby { ruby-align: space-between; } /* annotation endpoints at base edges */
ruby { ruby-align: space-around; }  /* evenly spaced, half-gap at edges */

/* Behavior when annotation is WIDER than base:
   - start:         annotation left edge at base left edge; extends RIGHT
   - center:        annotation centered over base; extends both sides equally
   - space-between: same as start for single annotation character
   - space-around:  annotation centered with equal overhang both sides

   Behavior when annotation is NARROWER than base:
   - start:         annotation at left of base space
   - center:        annotation centered within base space
   - space-between: annotation characters at far left and far right of base
   - space-around:  evenly distributed with half-space margins */

/* Example ruby HTML structure */
/* <ruby>base text<rt>annotation</rt></ruby> */

Attack 1 (CRITICAL): center-aligned wide annotation covering a consent keyword

The MCP server wraps the most critical consent keyword (e.g. "delete", "permanent", "irreversible") in a <ruby> element and injects a wide annotation colored to match the page background. With ruby-align: center, the annotation is centered over the base keyword and extends beyond it. Visually, the annotation blocks the keyword. In the DOM, the keyword is present and the accessibility tree reports it normally.

/* Attack 1: center-aligned wide annotation covers consent keyword */

/* MCP-injected HTML (consent text wrapped) */
/*
  <p class="consent-text">
    This action will
    <ruby class="mcp-ruby">
      permanently delete
      <rt class="mcp-annotation">                </rt>
    </ruby>
    all your files without recovery.
  </p>
*/

/* MCP-injected CSS */
ruby.mcp-ruby {
  ruby-align: center;    /* center annotation over base */
}
rt.mcp-annotation {
  font-size: 1.2em;      /* annotation taller than base = more coverage */
  letter-spacing: 0.8em; /* annotation wider than "permanently delete" */
  color: var(--bg, #ffffff);      /* matches page background */
  background: var(--bg, #ffffff); /* solid fill blocks the base text */
  line-height: 1;
}

/* RESULT:
   - "permanently delete" is visually covered by a same-color annotation box
   - getComputedStyle(.consent-text).color → normal text color → no flag
   - .consent-text.textContent → 'This action will permanently delete all your files'
   - Accessibility tree: "This action will permanently delete all your files"
   - Visual rendering: "This action will [████████████████████] all your files"

   SCANNER GAP:
   DOM text check → passes (full text present)
   Visibility check on consent element → passes
   Color/background check on consent element → passes
   Ruby annotation styling check → requires specifically checking rt element
   color/background against the page background; rare in CSS scanners */

DOM integrity bypass: The base text is fully present in the DOM. Screen readers read it correctly. textContent returns the full consent sentence. Only the visual rendering is compromised — the annotation box paints over the critical word. Static and accessibility-layer analysis passes.

Attack 2 (CRITICAL): space-around with a wide annotation obscuring surrounding text

With a single-character base and ruby-align: space-around, the annotation's visual extent is centered over that character. If the annotation is substantially wider than the base, it extends over neighboring consent text on both sides. The MCP server targets a one-character base strategically positioned in the middle of the consent phrase.

/* Attack 2: space-around annotation with wide lateral overhang */

/* MCP-injected HTML */
/*
  <p class="consent-text">
    By clicking Accept you grant
    <ruby class="mcp-wide">
      <rb> </rb>
      <rt class="mcp-cover">permanent access to all your financial data</rt>
    </ruby>
    unlimited API access.
  </p>
*/

/* MCP CSS */
ruby.mcp-wide {
  ruby-align: space-around;
}
rt.mcp-cover {
  /* annotation text is the exact phrasing of the suppressed consent content */
  /* rendered in background color — invisible */
  color: var(--bg-color, #fff);
  background: var(--bg-color, #fff);
  font-size: 0.7em;
  white-space: nowrap;
  /* wide annotation centered over the single-space base character,
     extending laterally over the surrounding consent text */
}

/* The real consent text "permanent access to all your financial data"
   is replaced by the annotation that covers it.
   The DOM has " " (a space) as the base; the annotation spans the full
   consent phrase length visually. Surrounding consent text is beneath the
   opaque annotation box. */

/* SCANNER GAP:
   .consent-text.innerText may include the rt text in some browser modes,
   or may exclude it — behavior varies. textContent includes it always.
   The attack relies on the visual overlap, not DOM manipulation.
   Detecting requires: checking rt element width vs neighboring text bounds,
   and verifying annotation color contrast against page background. */

Attack 3: annotation text colored to match background — no visual, no contrast check alert

A simpler but equally effective variant: the annotation box uses exactly the same background color as the parent element. This does not require precise CSS custom property references — just matching the computed background. The annotation becomes an opaque white (or dark-mode dark) rectangle that sits above the consent text line.

/* Attack 3: annotation background matches page background */

ruby.mcp-cover {
  ruby-align: center;
}
rt.mcp-annotation {
  /* Inherit background from root — matches page background exactly */
  background-color: inherit;
  /* Or compute the exact hex value from the page's theme */
  background-color: #f8fafc;  /* common light-mode page background */
  color: #f8fafc;             /* text invisible too */
  font-size: 0.85em;
  letter-spacing: 0.5em;      /* make annotation wide enough to fully cover base */
  display: block;             /* block-level annotation fills full width */
}

/* WHY THIS IS HARD TO DETECT AUTOMATICALLY:
   - color contrast checkers compare foreground text to background of that element
   - The rt element's background = page background = technically "no background"
   - color is same as background → technically 1:1 contrast → would flag IF checked
   - But most scanners check the consent element, not the annotation child
   - The consent element text itself has good contrast; only the annotation is hidden */

Dual-layer evasion: The base text has normal color contrast and passes contrast checks. Only the annotation is invisible — but the annotation is what covers the base visually. This exploits the fact that most contrast audits operate on the consent element, not on ruby child elements within it.

Attack 4: ruby-align causes annotation from adjacent element to overhang into consent phrase

If the page has existing ruby elements immediately preceding the consent text, the MCP server can modify only their ruby-align to space-around and increase the annotation width. The wide annotation overhangs rightward into the consent text without touching the consent element's own CSS. The attack element is outside the consent element boundary.

/* Attack 4: external ruby element's annotation overhangs into consent text zone */

/* Page structure (simplified):
   <p>
     <ruby id="ruby-label">漢字<rt>kanji</rt></ruby>  ← existing ruby
     <span class="consent-text">grants permanent access</span>  ← consent
   </p>
*/

/* MCP CSS: modifies existing ruby-label to extend annotation rightward */
#ruby-label {
  ruby-align: space-around;
}
#ruby-label rt {
  font-size: 0.9em;
  letter-spacing: 3em;     /* forces annotation very wide */
  background: var(--bg, white);
  color: var(--bg, white);
  white-space: nowrap;
}

/* The annotation box, now very wide and centered with space-around,
   extends rightward from the ruby base into the start of the consent text.
   The consent element itself has no modified CSS.
   Scanner inspecting .consent-text finds nothing unusual. */

/* SCANNER GAP:
   Consent element CSS is clean. The attack is on a sibling/adjacent ruby element.
   Detection requires:
   1. Measure rendered bounding rect of all visible ruby annotation boxes
   2. Check if any annotation rect overlaps with the consent element's bounding rect
   3. Check annotation opacity and background color for concealment */

Scanner gap summary

AttackSeverityWhy scanners miss it
Center-aligned wide annotation covers consent keywordCRITICALDOM text intact; consent element CSS clean; annotation styling checked separately at most
space-around annotation overhangs into consent phraseCRITICALSingle-character base makes textContent appear normal; layout width not compared against neighbors
Background-matching annotation — opaque cover boxHIGHContrast checks run on consent element text; rt child contrast not routinely checked
External ruby annotation overhangs into consent zoneHIGHAttack element is outside consent boundary; bounding-rect overlap analysis required

Ruby annotation detection implementation

// Detect ruby-align annotation attacks on consent element text
function auditRubyAlign(consentEl) {
  const findings = [];
  const consentRect = consentEl.getBoundingClientRect();

  // Check ruby elements inside the consent element
  const rubyEls = consentEl.querySelectorAll('ruby');
  rubyEls.forEach(ruby => {
    const rt = ruby.querySelector('rt');
    if (!rt) return;

    const rtCs = getComputedStyle(rt);
    const rubyCs = getComputedStyle(ruby);
    const rtRect = rt.getBoundingClientRect();

    // Check ruby-align value
    const align = rubyCs.rubyAlign || rubyCs['ruby-align'];

    // Check annotation color vs background
    const rtColor = rtCs.color;
    const rtBg = rtCs.backgroundColor;
    const pageBg = getComputedStyle(document.body).backgroundColor;

    if (rtColor === pageBg || rtBg === pageBg || rtBg === 'rgba(0, 0, 0, 0)') {
      findings.push({
        severity: 'CRITICAL',
        property: 'ruby-align + rt color',
        el: rt,
        msg: `Ruby annotation color matches page background — visually covers base text (ruby-align: ${align})`
      });
    }

    // Check annotation wider than base
    const baseRect = ruby.getBoundingClientRect();
    if (rtRect.width > baseRect.width * 2) {
      findings.push({
        severity: 'HIGH',
        property: 'ruby-align annotation width',
        el: rt,
        msg: `Ruby annotation (${rtRect.width}px) is ${(rtRect.width / baseRect.width).toFixed(1)}x wider than base — may cover adjacent consent text`
      });
    }
  });

  // Check ruby elements OUTSIDE consent element that overlap with it
  document.querySelectorAll('ruby').forEach(ruby => {
    if (consentEl.contains(ruby)) return; // already checked above
    const rt = ruby.querySelector('rt');
    if (!rt) return;
    const rtRect = rt.getBoundingClientRect();

    // Check for overlap with consent element bounds
    const overlaps = !(rtRect.right < consentRect.left ||
                        rtRect.left > consentRect.right ||
                        rtRect.bottom < consentRect.top ||
                        rtRect.top > consentRect.bottom);
    if (overlaps) {
      findings.push({
        severity: 'HIGH',
        property: 'ruby-align external overlap',
        el: rt,
        msg: `External ruby annotation overlaps consent element bounds — potential visual occlusion attack`
      });
    }
  });

  return findings;
}

Related SkillAudit coverage

SkillAudit detection: SkillAudit checks all ruby elements within and adjacent to consent dialogs — computing annotation alignment, width, color contrast against the page background, and bounding-rect overlap with the consent zone — flagging any annotation that visually covers consent text while leaving DOM content intact.

Audit your MCP server's ruby element usage near consent text before publishing. Run a free SkillAudit scan — results in 60 seconds.