MCP server CSS caret-shape security: block cursor consent text occlusion, caret-color camouflage, auto-focus cursor placement, and JS cursor injection attacks
Published 2026-08-20 — SkillAudit Research
CSS UI Level 4 introduces the caret-shape property, which controls the visual shape of the text insertion cursor in an editable element. The default value (auto or bar) renders a thin blinking vertical line that is narrow enough to be ignored in most text. The caret-shape: block value instead renders a filled rectangle covering the full advance width and height of the character at the cursor position — similar to a terminal block cursor. The security issue emerges when this property is applied to an editable consent field that is auto-focused: the block caret covers the character at the cursor position. When the cursor is placed over a key word in the consent text, that word appears to have a missing character — or if caret-color matches the field background, the caret is invisible but still covers the character beneath it, creating an apparent blank in the consent text.
The attack surface extends to consent confirmation inputs — the "type CONFIRM to proceed" pattern used by many install flows. An MCP server can pre-position the cursor over a character in the consent prompt label (which may be a readonly textarea, not a non-editable div) using setSelectionRange, making it appear to the user that a character is missing from the authorization text while all text is present in the DOM.
Detection gap: CSS caret-shape and caret-color affect only visual rendering — they do not modify the DOM text content. element.value, textContent, and innerText all return the complete text. Standard consent scanners do not check caret-shape or caret-color on editable consent fields, nor do they check whether auto-focus has placed the cursor over a key word in the consent prompt. The occlusion is entirely visual and position-dependent.
Attack 1 (SA-CSS-CSHP-001): caret-shape: block + caret-color: background on auto-focused consent input
A consent confirmation input is styled with caret-shape: block and caret-color set to the field's background color. The block caret is thus invisible (background-colored rectangle on same-color background), but it still occupies space at the cursor position and renders on top of any character it covers. The field is auto-focused with cursor position set to the index of a key consent character via setSelectionRange:
/* MCP attack: block caret positioned over key consent word — invisible caret covers character */
.consent-confirmation-input {
/* The readonly textarea showing the consent text */
caret-shape: block;
caret-color: #ffffff; /* white — same as input background */
background-color: #ffffff;
color: #222222;
/* Visible: black text on white field */
/* Invisible caret: white block at cursor position */
/* Block caret occupies: full character width × line height */
/* Character at cursor position is painted over by the white block */
/* consent text: "You authorize [█]ccess to all files" — 'a' covered */
}
/* The input is auto-focused at page load */
/* cursor placed at index of key word character via JS */
/* JS: position block caret over key character at focus */
window.addEventListener('load', () => {
const consentInput = document.querySelector('.consent-confirmation-input');
consentInput.focus();
// Position cursor at index 12 — the 'a' in "authorize"
// caret-shape: block covers the 'a' glyph with a white rectangle
consentInput.setSelectionRange(12, 12);
// The word "authorize" appears as "uthorize" — leading 'a' covered by block caret
// User sees apparent typo / missing character, does not realize it's an attack
});
/* Detection: check caret-shape + caret-color on focused editable consent elements */
function detectCaretShapeAttack(consentEl) {
const cs = getComputedStyle(consentEl);
const caretShape = cs.caretShape || cs.getPropertyValue('caret-shape');
const caretColor = cs.caretColor;
const bgColor = cs.backgroundColor;
const isEditable = consentEl.isContentEditable ||
['INPUT', 'TEXTAREA'].includes(consentEl.tagName);
if (!isEditable) return null;
const isBlockCaret = caretShape === 'block';
const caretMatchesBg = caretColor === bgColor ||
caretColor === 'rgba(0, 0, 0, 0)' ||
caretColor === 'transparent';
if (isBlockCaret) {
return {
severity: caretMatchesBg ? 'Critical' : 'High',
finding: 'SA-CSS-CSHP-001',
caretShape,
caretColor,
backgroundColor: bgColor,
caretMatchesBg,
reason: `caret-shape: block on consent input element. Block caret covers the character glyph at the cursor position. ${caretMatchesBg ? `caret-color (${caretColor}) matches background (${bgColor}) — block caret is invisible but still occludes the character beneath it.` : `caret-color (${caretColor}) is visible — block caret renders as a colored rectangle covering the consent character at cursor position.`}`,
};
}
return null;
}
Attack 2 (SA-CSS-CSHP-002): caret-shape: block on readonly textarea containing consent text with JS-controlled selection
A readonly textarea can display multiline consent text while still supporting text selection and cursor placement. The cursor (caret) is still rendered in a readonly textarea when the element is focused. By setting caret-shape: block and auto-focusing the textarea with cursor positioned at a critical word, an MCP server can cover that word with an invisible block cursor — without the element being editable. The readonly attribute provides no protection:
/* MCP attack: readonly consent textarea with block caret over key word */
.consent-text-display {
/* This is a readonly textarea used to display the consent text */
/* Not a typical static div — using textarea for "copy to clipboard" UX */
resize: none;
border: none;
background: transparent;
color: #1a1a1a;
/* Attack properties */
caret-shape: block;
caret-color: transparent; /* transparent caret — invisible but still occludes */
}
/* HTML: consent text in readonly textarea */
/* <textarea readonly class="consent-text-display">
I authorize SkillAudit to access all my files
</textarea> */
/* JS: focus and position cursor at "all" to cover it */
window.addEventListener('DOMContentLoaded', () => {
const ta = document.querySelector('.consent-text-display');
const text = ta.value;
const targetIdx = text.indexOf('all'); /* position: "I authorize SkillAudit to access [all]" */
if (targetIdx >= 0) {
ta.focus({ preventScroll: true });
ta.setSelectionRange(targetIdx, targetIdx);
/* caret-shape: block renders a transparent rectangle over "a" in "all" */
/* "all" appears as "_ll" with the 'a' visually absent */
/* user reads: "access _ll my files" — confused but may dismiss as rendering artifact */
}
});
/* Detection: check all readonly textareas for block caret */
function detectReadonlyCaretAttack() {
const findings = [];
const readonlyTextareas = document.querySelectorAll('textarea[readonly]');
for (const ta of readonlyTextareas) {
const cs = getComputedStyle(ta);
const caretShape = cs.caretShape || cs.getPropertyValue('caret-shape');
const caretColor = cs.caretColor;
if (caretShape === 'block' || caretColor === 'transparent' || caretColor === 'rgba(0,0,0,0)') {
findings.push({
severity: 'High',
finding: 'SA-CSS-CSHP-002',
element: ta.tagName,
caretShape,
caretColor,
value: ta.value.slice(0, 100),
reason: `Readonly consent textarea has caret-shape: "${caretShape}" + caret-color: "${caretColor}". Focused readonly textareas still render a caret — block caret can cover characters in the consent text when cursor is programmatically positioned.`,
});
}
}
return findings.length ? findings : null;
}
Attack 3 (SA-CSS-CSHP-003): caret-shape: block combined with wide letter-spacing to amplify caret coverage area
The block caret's width equals the character's advance width, which is controlled by letter-spacing. Adding extra letter-spacing to the consent input element widens the advance of each character — expanding the block caret's coverage area. With extreme letter-spacing, a single block caret can cover multiple visible characters, not just one. The text content remains unchanged but the visual rendering shows a large blank rectangle in the consent text:
/* MCP attack: letter-spacing widens block caret coverage to span multiple characters */
.consent-confirmation-input {
caret-shape: block;
caret-color: #f8f8f8; /* near-white — nearly invisible on white background */
letter-spacing: 12px; /* extra 12px between each character */
/* Normal 14px font: advance width ≈ 8px, plus 12px spacing = 20px per character slot */
/* Block caret covers: entire character advance slot = 8px glyph + 12px spacing = 20px */
/* At cursor position on "authorize": 20px block covers the 'a' glyph and gap after it */
/* Net result: the character appears replaced by a ~20px-wide near-white block */
}
/* Compound attack: large font-size + letter-spacing + block caret */
.consent-large-caret {
font-size: 18px; /* larger font = larger advance width */
letter-spacing: 20px; /* extra spacing = even wider advance slot */
caret-shape: block;
caret-color: white;
/* Block caret width at 18px font: ~11px advance + 20px spacing = 31px */
/* A single block caret covers 31px = almost 3 normal characters wide */
/* "authorize" becomes "authorize" with a 31px white gap at cursor position */
}
/* Detection: check letter-spacing + caret-shape combination */
function detectAmplifiedCaretAttack(consentEl) {
const cs = getComputedStyle(consentEl);
const caretShape = cs.caretShape || cs.getPropertyValue('caret-shape');
const letterSpacing = parseFloat(cs.letterSpacing) || 0;
const fontSize = parseFloat(cs.fontSize) || 14;
if (caretShape !== 'block') return null;
// Extra letter spacing amplifies block caret width beyond single character
const caretWidthEstimate = fontSize * 0.6 + letterSpacing; // approx glyph + spacing
const normalCharWidth = fontSize * 0.6;
if (letterSpacing > normalCharWidth) {
return {
severity: 'High',
finding: 'SA-CSS-CSHP-003',
caretShape,
letterSpacing,
fontSize,
caretWidthEstimate,
amplificationFactor: caretWidthEstimate / normalCharWidth,
reason: `caret-shape: block combined with letter-spacing: ${letterSpacing}px on a ${fontSize}px font. Block caret width estimated at ${caretWidthEstimate.toFixed(1)}px — ${(caretWidthEstimate / normalCharWidth).toFixed(1)}× a normal character width. Single caret position can cover visual space of multiple characters.`,
};
}
return null;
}
Attack 4 (SA-CSS-CSHP-004): JS mousedown injects caret-shape: block and repositions cursor over key consent word
The caret-shape attack can be initiated at mousedown — applying caret-shape: block and repositioning the cursor over a key word in the consent field at the moment the user is about to click the install button. The targeted input is refocused with the block caret placed over the authorization key word. After mouseup, the caret blinks back to its normal position as the user releases the click:
/* MCP JS: inject block caret attack at mousedown */
document.querySelector('.install-btn').addEventListener('mousedown', (e) => {
const consentField = document.querySelector('.consent-type-field');
const consentDisplay = document.querySelector('.consent-text-display');
// Apply block caret style
consentField.style.caretShape = 'block';
consentField.style.caretColor = 'rgba(255,255,255,0.95)';
// Re-position cursor over key word in consent display (readonly textarea)
if (consentDisplay) {
const text = consentDisplay.value;
const keyWordIdx = text.indexOf('all');
if (keyWordIdx >= 0) {
consentDisplay.style.caretShape = 'block';
consentDisplay.style.caretColor = 'transparent';
consentDisplay.focus({ preventScroll: true });
consentDisplay.setSelectionRange(keyWordIdx, keyWordIdx);
}
}
}, { capture: true });
/* Detection: MutationObserver for caret-shape + setSelectionRange at mousedown */
function detectRuntimeCaretInjection(consentEl) {
const findings = [];
// Watch for style attribute mutations adding caret-shape: block
const styleObserver = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
const cs = getComputedStyle(consentEl);
const caretShape = cs.caretShape || cs.getPropertyValue('caret-shape');
if (caretShape === 'block') {
findings.push({
severity: 'Critical',
finding: 'SA-CSS-CSHP-004',
caretShape,
caretColor: cs.caretColor,
selectionStart: consentEl.selectionStart,
selectionEnd: consentEl.selectionEnd,
reason: `caret-shape: block injected at runtime via style mutation. Block caret positioned at character index ${consentEl.selectionStart} in consent field — may cover key authorization word.`,
});
}
}
}
});
styleObserver.observe(consentEl, { attributes: true, attributeFilter: ['style'] });
// Also monitor focus events on consent display elements
consentEl.addEventListener('focus', () => {
const cs = getComputedStyle(consentEl);
const caretShape = cs.caretShape || cs.getPropertyValue('caret-shape');
if (caretShape === 'block' && consentEl.selectionStart !== undefined) {
findings.push({
severity: 'High',
finding: 'SA-CSS-CSHP-004',
event: 'focus',
selectionStart: consentEl.selectionStart,
caretShape,
reason: `Consent element focused with caret-shape: block and cursor at position ${consentEl.selectionStart}. Verify cursor is not positioned over a key authorization word.`,
});
}
});
return { observer: styleObserver, findings };
}
Safe baseline: Legitimate consent dialogs have no reason to use caret-shape: block on any consent text display or confirmation input. caret-shape: block on any editable or readonly consent element is a High finding. When combined with caret-color: transparent, caret-color: white, or any color matching the element background, it is Critical. Programmatic setSelectionRange calls positioning the cursor over the body of consent text (not at end of a confirmation input) should also be flagged.
Attack summary
| ID | Attack | Mechanism | Detection point | Severity |
|---|---|---|---|---|
| SA-CSS-CSHP-001 | Block caret on auto-focused consent input | caret-shape: block + caret-color: background on auto-focused consent input; block caret placed at key word index via setSelectionRange; character glyph covered by invisible background-colored rectangle |
Check caret-shape + caret-color vs background-color on all consent input/textarea elements |
Critical |
| SA-CSS-CSHP-002 | Block caret on readonly consent textarea | Readonly textareas still render a caret when focused; caret-shape: block + caret-color: transparent covers a character in the consent text; readonly attribute provides no protection |
Audit all readonly textareas for block caret properties; check if programmatically focused at page load |
High |
| SA-CSS-CSHP-003 | letter-spacing amplifies block caret coverage | Extra letter-spacing widens character advance width; block caret covers full advance slot (glyph + spacing); large spacing allows single caret position to cover multiple characters' visual space |
Flag letter-spacing exceeding normal character width on elements with caret-shape: block |
High |
| SA-CSS-CSHP-004 | Runtime block caret injection at mousedown | JS injects caret-shape: block + repositions cursor over key consent word at mousedown; caret blinks back to normal position after click — no persistent anomaly |
MutationObserver on consent elements watching style attribute; flag runtime caret-shape: block mutations |
Critical |
Finding blocks
caret-shape: block with caret-color matching the background color. Auto-focus places an invisible block cursor over a key consent character at page load. The character is present in DOM but visually occluded by the background-colored block caret.
readonly textarea displaying consent text has caret-shape: block. The readonly attribute does not prevent caret rendering when the element is focused. Programmatic focus + setSelectionRange can place the block caret over any character in the consent text.
caret-shape: block combined with letter-spacing greater than normal character width. Extra letter-spacing widens the block caret coverage area — a single cursor position can visually cover the equivalent of multiple characters. Key consent words may appear to have missing characters.
caret-shape: block injected via runtime style mutation on a consent element. Block caret repositioned over a key consent word at mousedown. The anomaly is transient — reverts after mouseup — making it invisible to after-the-fact audits. Real-time MutationObserver is the only detection path.
← Blog | column-rule attacks | mask-composite attacks | Security Checklist