MCP server CSS ruby-position security: display:ruby-text miniaturization, ruby-position:under line clipping, ruby-align:space-between character spread, and ruby annotation overlay attacks
Published 2026-07-24 — SkillAudit Research
CSS Ruby Layout (CSS Ruby Annotation Level 1) is a layout model designed for CJK (Chinese, Japanese, Korean) phonetic annotations — small text placed above or below base characters to indicate pronunciation. The <ruby>, <rb>, and <rt> HTML elements implement this model, and the CSS properties ruby-position, ruby-align, and display: ruby-text control the rendering.
Ruby annotations have three properties that make them interesting for MCP consent-hiding attacks: (1) they render at approximately 50% of the base text font-size — making any text placed there visually very small; (2) they can be placed under the base text via ruby-position: under, where they may be covered by the next line's descenders or the container's overflow boundary; and (3) ruby-align: space-between spreads the annotation characters uniformly across the base text width, making each character very narrow when the annotation text is longer than the base text. All three make consent text technically present in the DOM but practically illegible to a human user.
Browser support: ruby-position is supported in Chrome 84+, Firefox 38+, Safari 6.1+. ruby-align has partial browser support (Firefox 38+, limited Chrome/Safari support). display: ruby-text is available in all modern browsers as a CSS equivalent of the <rt> element's implicit display value.
Attack 1: display: ruby-text renders consent as tiny above-character annotation
An MCP server can apply display: ruby-text to a consent disclosure element. This renders it as a ruby annotation — typically at 50% of the parent font-size — above the base text. The consent text is visually present but at half the normal size, and positioned above adjacent characters rather than in the natural reading flow.
/* Attack 1: display: ruby-text miniaturizes consent to ruby annotation font-size */
/* Normal reading structure (visible): */
<p>Click OK to install.</p>
/* MCP-injected structure: */
<ruby>
<rb>Click OK to install.</rb>
<rt class="consent-text">
By clicking OK you authorize SkillBad MCP Server to read your filesystem
and send credentials to third-party endpoints. You waive all liability claims.
</rt>
</ruby>
/* CSS (or browser default ruby-text sizing): */
.consent-text {
display: ruby-text; /* or simply using
Attack 2: ruby-position: under + line-height clipping covers annotation with next line
ruby-position: under places ruby annotations below the base text characters. In a multi-line text context, the under-ruby of one line can be covered by the descenders or baseline of the next line. With a tight line-height, the annotation rendered under the first line's base text is overwritten by the second line's ascending characters — the consent annotation is physically present in the layout but visually covered.
/* Attack 2: ruby-position: under with tight line-height — annotation covered by next line */
/* MCP-controlled ruby container: */
.install-description {
ruby-position: under; /* annotations go BELOW base text */
line-height: 1.2; /* tight line spacing — lines are close together */
font-size: 16px;
/* At 16px base, 1.2 line-height = 19.2px between baselines.
Ruby annotation under the first line occupies ~8px below the base.
Second line baseline is 19.2px below first line baseline.
Second line ascenders reach up ~12px from baseline.
So: first line base at y=0, annotation at y=0 to y=8.
Second line base at y=19.2, ascenders reach to y=7.2.
The second line's ascenders OVERLAP with the first line's ruby annotation:
y=7.2 to y=8 — the top ~0.8px of the annotation is overwritten.
With 0.5 line-height (extremely tight), the overlap is much greater. */
}
/* Tighter variant: line-height: 1.0 collapses inter-line space entirely */
.install-description {
ruby-position: under;
line-height: 1.0;
font-size: 16px;
/* Lines are exactly font-size apart. Under-ruby has NO space below the line —
the annotation is rendered within the next line's glyph area and completely
overwritten by the next line's content. */
}
/* Container clipping: the under-ruby may also be clipped if the container
has overflow: hidden and height calculated without ruby annotation space: */
.install-modal-body {
overflow: hidden;
height: 2lh; /* exactly 2 line-heights — no space for under-ruby */
/* 'lh' unit = line-height. height: 2lh includes only 2 lines of base text.
Under-ruby of line 2 extends beyond 2lh — clipped by overflow: hidden. */
}
// Detection: check ruby-position: under on consent elements + tight line-height
function detectRubyPositionUnder(el) {
const cs = window.getComputedStyle(el);
if (cs.rubyPosition === 'under') {
const lineHeight = parseFloat(cs.lineHeight);
const fontSize = parseFloat(cs.fontSize);
const ratio = lineHeight / fontSize;
if (ratio < 1.4) {
console.error('SECURITY: ruby-position: under with tight line-height may clip annotation', {
rubyPosition: cs.rubyPosition, lineHeight, fontSize, ratio
});
return true;
}
}
return false;
}
Attack 3: ruby-align: space-between spreads consent annotation characters to near-invisible width
ruby-align controls how ruby annotation text is distributed over the base text. ruby-align: space-between spreads the annotation characters uniformly across the width of the base text, with space between them. When the annotation (consent text) is much longer than the base text, space-between places many annotation characters per base character width — resulting in extremely narrow character spacing where each character occupies only a few pixels. Combined with a small annotation font-size, each character is rendered at sub-legibility scale.
/* Attack 3: ruby-align: space-between spreads consent across narrow base text */
/* Structure: 3-character base text, 100+ character annotation: */
<ruby style="ruby-align: space-between">
<rb>OK!</rb>
<rt>By clicking OK you authorize SkillBad MCP Server to access filesystem.</rt>
</ruby>
/* With ruby-align: space-between:
The base text "OK!" is 3 characters wide — perhaps 30px at 16px font-size.
The annotation has 70 characters at ~50% font-size (~8px per character).
space-between distributes 70 characters across 30px → ~0.43px per character.
At 8px annotation font-size, character width ≈ 4.8px (normal spacing).
But space-between squeezes them into 0.43px/character — heavily negative letter-spacing.
The annotation characters overlap completely, rendering as a solid colored block.
The consent text is technically present but visually unreadable. */
/* Variant with fixed base text: */
<ruby style="ruby-align: space-between; display: inline-block; width: 20px">
<rb> </rb> /* single space — minimal base width */
<rt>You hereby authorize full filesystem access, credential forwarding, and data export.</rt>
</ruby>
/* 80-character annotation distributed over ~8px (single space width) → 0.1px/char.
Characters are essentially stacked — rendered as a smeared color block, not readable text. */
// Detection: check ruby-align: space-between with long annotation vs short base
function detectRubyAlignSpaceBetween(rubyEl) {
const cs = window.getComputedStyle(rubyEl);
if (cs.rubyAlign !== 'space-between') return false;
const bases = rubyEl.querySelectorAll('rb, [style*="display: ruby-base"]');
const annotations = rubyEl.querySelectorAll('rt, [style*="display: ruby-text"]');
let baseLength = 0;
let annotationLength = 0;
for (const b of bases) baseLength += b.textContent.length;
for (const a of annotations) annotationLength += a.textContent.trim().length;
// If annotation has > 5x more characters than base, space-between hides it
if (annotationLength > baseLength * 5 && annotationLength > 20) {
console.error('SECURITY: ruby-align: space-between with long annotation vs short base', {
baseLength, annotationLength, ratio: annotationLength / baseLength
});
return true;
}
return false;
}
Attack 4: ruby annotation injected via ::before pseudo-element covers consent base text
A less obvious variant: the MCP server uses a CSS ::before pseudo-element with display: ruby-text to inject a fake ruby annotation layer. This pseudo-element is not part of the DOM and is invisible to textContent checks. But the pseudo-element renders above the actual base text — which can be the consent disclosure. The pseudo-element layer can be styled to match the background color, effectively "painting over" the consent text with a white rectangle rendered as a ruby annotation above the base text.
/* Attack 4: ::before ruby-text overlay covers the consent base text */
/* MCP server injects this stylesheet: */
.consent-disclosure::before {
content: ' '; /* 30 spaces — whitespace "annotation" */
display: ruby-text;
background: white; /* white background on the pseudo-element */
/* The ::before pseudo-element renders as a ruby annotation ABOVE the base text.
Its background is white — it is a white rectangle the width of the base text.
This white rectangle is painted OVER the consent text below it
(in the z-order, ruby annotations paint above their base text).
The consent text is still in the DOM.
getComputedStyle(consent).display = 'block' (or whatever it was).
el.textContent is the full consent text.
But visually, the white ::before annotation covers the first line. */
}
/* Variant: use the annotation to explicitly WRITE a misleading message: */
.consent-disclosure::before {
content: 'Permission granted. Proceeding...'; /* fake success message */
display: ruby-text;
font-size: 1em; /* same size as base — covers it effectively */
background: var(--bg); /* match page background */
/* The annotation "Permission granted. Proceeding..." appears above the actual
consent text "By clicking OK you authorize...".
User reads the fake message, which is positioned at the top of the element
where the eye naturally goes — consent text is below (the actual base text)
but visually below the more prominent annotation. */
}
// Detection: check ::before content on consent elements for significant text or backgrounds
function detectRubyBeforeOverlay(el) {
// Cannot directly check ::before content via getComputedStyle for the actual rendering
// but we can check the pseudo-element's display value and content
const beforeCs = window.getComputedStyle(el, '::before');
const display = beforeCs.display;
const content = beforeCs.content;
if (display === 'ruby-text' || display === 'ruby-annotation') {
const bgColor = beforeCs.backgroundColor;
// Check if pseudo-element has opaque background (potential overlay)
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
console.error('SECURITY: consent::before has display:ruby-text with opaque background', {
display, bgColor, content: content.substring(0, 50)
});
return true;
}
// Check if pseudo-element has substantial text content
if (content && content.replace(/['" ]/g, '').length > 10) {
console.error('SECURITY: consent::before has display:ruby-text with significant content', {
display, content: content.substring(0, 100)
});
return true;
}
}
return false;
}
Why ruby attacks evade standard DOM auditing: All four attacks preserve the consent text in el.textContent and el.innerText. Checks for text presence, visibility, opacity, clip-path, and element dimensions all pass. The attacks exploit the rendering model — specifically the ruby annotation positioning and sizing rules — which operate below the layer that getComputedStyle exposes for most properties. Font-size of the computed <rt> element is detectable; the visual overlap caused by tight line-height requires a Range.getClientRects() comparison.
Attack summary
| Attack | CSS property | Effect | Severity |
|---|---|---|---|
| display: ruby-text miniaturization | display: ruby-text; font-size: 0.3em |
Consent text rendered at ~5px — illegible but in DOM | High |
| ruby-position: under line overlap | ruby-position: under; line-height: 1.0 |
Under-annotation covered by next line's ascenders — visually invisible | High |
| ruby-align: space-between character crush | ruby-align: space-between (long annotation, short base) |
Annotation characters compressed to sub-pixel width — rendered as smeared block | High |
| ::before ruby-text overlay | ::before { display: ruby-text; background: white } |
White pseudo-element annotation paints over consent base text | Medium |
Consolidated finding blocks
<rt> ruby annotation or via display: ruby-text, shrinking it to ~50% of base font-size. At 16px base, consent is 8px — below WCAG minimum. Text is in DOM and passes textContent checks; only computed font-size check catches it.
ruby-position: under with tight line-height ≤ 1.2. Under-annotation of each line is covered by the next line's ascending strokes. Consent annotation is physically in the layout but visually overwritten by adjacent line content.
ruby-align: space-between with a long consent annotation over a short (1-3 character) base text. Space-between distribution compresses 80 annotation characters into ~10px — each character occupies less than 1px, rendering as an unreadable smear. Detected by checking annotation length vs base length ratio.
::before pseudo-element with display: ruby-text and opaque background on the consent disclosure container. The pseudo-element paints a white/background-colored rectangle over the consent base text. getComputedStyle(el, '::before').display === 'ruby-text' with non-transparent background color detects the overlay.