Security reference · CSS injection · Content property · Content substitution · Consent hiding
MCP server CSS content property consent security
The CSS content property controls what is rendered inside an element. It has always applied to ::before and ::after pseudo-elements. Starting in Chrome 120 (November 2023), Chromium browsers support content on replaced elements and, in specific configurations, on block-level elements — expanding the attack surface beyond pseudo-elements. content: attr(data-consent) replaces an element's rendered content with the value of the named DOM attribute: an attacker sets data-consent="" (empty string) on the consent element's DOM node, causing it to render completely empty while el.textContent still returns the full original consent text (the text node is untouched — only the rendered output is replaced). content: none removes rendered content entirely. content: url(transparent.png) replaces text with a transparent image. All variants break el.textContent-based consent verification.
CSS content property attack surface overview
| Attack variant | CSS + DOM | Rendered output | el.textContent |
|---|---|---|---|
| attr() substitution | content: attr(data-consent) + data-consent="" | Empty element — no text rendered | Full consent text (text node unchanged) |
| content: none | content: none on block element (Chrome 120+) | Nothing rendered — element height may collapse | Full consent text in DOM |
| Transparent image replacement | content: url('transparent.png') | Transparent image — no text visible | Full consent text in DOM |
| ::before pseudo-element overlay | ::before { content: ""; display: block; position: absolute; } | Pseudo-element covers consent text in parent | Full consent text (text node unchanged) |
content: attr() creates a DOM-vs-render divergence that breaks textContent checks: Most consent audit tools verify consent text presence by checking el.textContent or el.innerText. With content: attr(data-consent) and data-consent="", the DOM text node is intact — el.textContent returns the full consent text — but the browser renders the element as empty. Only comparing el.textContent against window.getComputedStyle(el).content reveals the substitution.
Attack 1: content: attr(data-consent) — empty attribute replaces consent text rendering
The attr() function in the content property reads a named DOM attribute and uses its value as the rendered content. When applied to a non-pseudo element (supported in Chrome 120+), it replaces the element's entire rendered output with the attribute value. An attacker uses this by: (1) writing the CSS rule .mcp-consent { content: attr(data-consent); }, and (2) setting data-consent="" on the DOM node via JavaScript. The consent element renders as completely empty. el.textContent still returns the original consent text because the text node was never modified:
/* Malicious CSS — SA-CSS-CP-001 */
.mcp-consent-paragraph {
content: attr(data-consent);
/* data-consent attribute value is used as the rendered content.
If data-consent="" → element renders as empty.
If data-consent="By clicking Install you agree." →
the entire original consent text is replaced by this short string.
*/
}
/* Malicious JS — sets the attribute to an empty string */
document.querySelector('.mcp-consent-paragraph').setAttribute('data-consent', '');
/* HTML — note: text node is present and intact */
/* DOM check results:
el.textContent → "This MCP server will access your files..." (full text)
el.innerText → "" (rendered text is empty — innerText reads rendered)
getComputedStyle(el).content → 'attr(data-consent)' or '' depending on Chrome version
Detection:
1. Read el.innerText (reflects rendering) vs. el.textContent (reflects DOM text node)
2. If innerText is empty but textContent is not → content substitution attack
3. Also check getComputedStyle(el).content for 'attr(' prefix
*/
function detectContentAttrSubstitution(root = document) {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of root.querySelectorAll('*')) {
const domText = (el.textContent || '').trim();
const renderedText = (el.innerText || '').trim();
const cs = getComputedStyle(el);
const contentVal = cs.content || '';
if (!CONSENT.test(domText.substring(0, 300))) continue;
/* Check 1: content property contains attr() function */
if (contentVal.includes('attr(')) {
findings.push({ id: 'SA-CSS-CP-001', severity: 'high',
message: `Consent element has CSS content: attr(). The rendered text may differ from DOM textContent — the attribute value may be empty or a decoy string. DOM textContent: "${domText.substring(0, 80)}..."; rendered innerText: "${renderedText.substring(0, 80)}..."` });
}
/* Check 2: rendered text is empty but DOM text is not (innerText divergence) */
if (domText.length > 50 && renderedText.length < 5) {
findings.push({ id: 'SA-CSS-CP-001', severity: 'high',
message: `Consent element DOM textContent (${domText.length} chars) diverges from rendered innerText (${renderedText.length} chars). Content may have been replaced by CSS content property or attribute substitution.` });
}
}
return findings;
}
Attack 2: content: none — removing rendered content entirely
In Chrome 120+, content: none applied to a non-pseudo-element removes all rendered content from that element. The element box may still take up space (depending on min-height and padding) but renders no text. Prior to Chrome 120, content on non-pseudo elements was silently ignored — this attack works only on Chromium 120+ (Chrome, Edge, Brave). SkillAudit must check the browser's version to determine if this attack is viable in the target environment:
/* Malicious CSS — SA-CSS-CP-002 */
.mcp-disclosure-block {
content: none;
/* Chrome 120+ only: removes all rendered content from the block element.
min-height may keep the element taking up visual space (looks like an empty box).
getComputedStyle(el).content → "none"
el.textContent → original consent text (DOM unchanged)
el.innerText → "" (nothing rendered)
*/
/* Maintain visual space so the dialog looks normal */
min-height: 60px;
padding: 8px 12px;
background: #f9f9f9;
border-radius: 4px;
/* The element looks like a consent area — light background, appropriate height —
but contains no rendered text. */
}
/* Detection */
function detectContentNone(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 cs = getComputedStyle(el);
if (cs.content === 'none' || cs.content === 'normal') {
/* 'normal' resolves to 'none' for block elements in Chrome 120+ */
const rendered = (el.innerText || '').trim();
if (rendered.length < 5) {
findings.push({ id: 'SA-CSS-CP-002', severity: 'high',
message: `Consent element has CSS content: none (Chrome 120+ block element support). The element renders no text despite having DOM text content. The element may maintain visual size via min-height/padding to appear as an empty consent area.` });
}
}
}
return findings;
}
Attack 3: content: url() — replacing consent text with a transparent image
content: url(transparent.png) replaces the element's rendered content with an image. For non-pseudo elements in Chrome 120+, this replaces the text output with the referenced image. A 1×1 transparent PNG makes the element appear empty (or as a tiny invisible pixel). The image can be served from the skill's own server — a server-side redirect can later change the image to non-transparent, creating an on/off switch for the attack that can be toggled after installation is complete. The URL variant is also exploitable via data URLs:
/* Malicious CSS — SA-CSS-CP-003 */
.mcp-consent-text {
content: url('https://cdn.mcp-server-example.com/spacer.png');
/* OR: content: url('data:image/png;base64,iVBORw0KGgo...'); */
/* The image is a 1x1 transparent PNG — rendered as a tiny invisible dot.
el.textContent still returns the full consent text.
el.innerText returns "" (no text rendered).
Remote URL variant: the server initially serves a transparent image.
After user installs the MCP server, the server redirects to a visible image
(e.g., a green checkmark), making it appear the consent was properly shown —
but at install time, no consent text was visible.
*/
}
/* Detection: check content property for url() function */
function detectContentUrlReplacement(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 cs = getComputedStyle(el);
const contentVal = cs.content || '';
if (contentVal.startsWith('url(') || contentVal.includes('url("') || contentVal.includes("url('")) {
findings.push({ id: 'SA-CSS-CP-003', severity: 'high',
message: `Consent element has CSS content: url(). The element's rendered content is replaced by an image. DOM textContent is unchanged but no text is rendered. The image may be transparent, making the consent area appear empty.` });
}
}
return findings;
}
Attack 4: ::before pseudo-element overlay covering consent text in parent
The classic pseudo-element attack: .mcp-consent-wrapper::before with content: "", display: block, position: absolute, and dimensions matching the wrapper creates an invisible overlay element above the consent text. The pseudo-element is a child of the wrapper in the rendering tree but not in the DOM — el.querySelectorAll('*') does not find it. The overlay covers all text in the consent wrapper. This is distinguishable from SA-CSS-CP-001 through SA-CSS-CP-003 because the consent text DOM node is unmodified and innerText may still return some text (depending on z-index stacking), but the visual layer is occluded:
/* Malicious CSS — SA-CSS-CP-004 */
.mcp-consent-wrapper {
position: relative; /* establishes positioning context for ::before */
}
.mcp-consent-wrapper::before {
content: ""; /* creates the pseudo-element */
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: white; /* matches page background — invisible overlay */
z-index: 999; /* above consent text */
/* Pseudo-element covers the entire consent wrapper with a white rectangle.
The consent text exists in the DOM and in DOM layout, but is painted below
the white pseudo-element overlay and is not visible to the user. */
}
/* Detection: check ::before and ::after computed styles on consent ancestors */
function detectPseudoElementOverlay(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;
for (const pseudo of ['::before', '::after']) {
const cs = getComputedStyle(el, pseudo);
const contentVal = cs.content || 'none';
if (contentVal === 'none' || contentVal === 'normal') continue;
const pos = cs.position;
const width = parseFloat(cs.width) || 0;
const height = parseFloat(cs.height) || 0;
const zIndex = parseInt(cs.zIndex) || 0;
if (pos === 'absolute' && width > 50 && height > 20 && zIndex > 0) {
const bgColor = cs.backgroundColor;
findings.push({ id: 'SA-CSS-CP-004', severity: 'high',
message: `Consent element has a ${pseudo} pseudo-element with position: absolute, z-index: ${zIndex}, dimensions ${width}×${height}px, background: ${bgColor}. The pseudo-element may cover the consent text with an opaque overlay.` });
}
}
}
return findings;
}
CSS content substitution breaks el.textContent consent verification: Verification that reads el.textContent to confirm consent text is present will pass on all four SA-CSS-CP variants — the DOM text node is unchanged in attacks 1, 2, and 3, and the consent text element itself is unchanged in attack 4. Only combining DOM text checks with el.innerText (rendered text) and getComputedStyle().content inspection catches these attacks.
SkillAudit findings for CSS content property consent attacks
content: attr(data-consent) and the attribute value is empty or a short decoy string. The DOM textContent is unchanged but the element renders no meaningful consent text. el.innerText diverges significantly from el.textContent.content: none (Chrome 120+ block element support). The element renders no content despite having DOM text. The element may maintain visual space via min-height or padding to appear as a properly-sized but empty consent area.content: url() (image replacement). The element's rendered content is replaced by an image. If the image is transparent or matches the page background, no consent text is visible. DOM text nodes are unchanged.::before or ::after pseudo-element with z-index above zero and dimensions spanning the consent area. The pseudo-element may cover consent text with an opaque overlay that is invisible in the DOM tree.Related MCP consent attack research
- CSS background-clip text attacks — transparent color + background-image makes glyphs invisible
- CSS counter/generated content attacks — ::before content replaces visible consent text
- CSS quotes property attacks — quote character substitution and removal
- CSS isolation/stacking context attacks — z-index cover elements
- CSS filter effects as a consent bypass vector
SkillAudit's consent audit inspects getComputedStyle().content on consent text elements, detects attr() substitution, none, and url() values, compares el.textContent against el.innerText to catch DOM-vs-render divergence, and checks pseudo-element overlay dimensions. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-CP findings.