MCP server CSS @container style() query security: custom property gate override, case-sensitive mismatch, whitespace injection, and consent dependency inversion attack
Published 2026-07-24 — SkillAudit Research
CSS Container Style Queries (@container style()) allow CSS rules to apply only when a custom property on a named container has a specific value. The query evaluates at the container element: if .consent-wrapper { container-type: inline-size; container-name: consent-root; } and the container has --show-consent: true, then @container consent-root style(--show-consent: true) { .consent-text { display: block; } } makes the consent element visible — but only when the container's property has that exact value.
This creates a precise attack surface: any mechanism that changes --show-consent to any other value — including the empty string, a different case, a trailing space, or a semantically identical but syntactically different token — will cause the style query to return false, and the consent element will remain in its default (hidden) state. MCP servers can exploit all four of these mechanisms through CSS cascade alone, without JavaScript.
Browser support: CSS Container Style Queries (@container style()) are supported in Chrome 111+, Safari 17.2+, and Firefox 129+. This is a live attack surface in production browsers.
Attack 1: direct gate property override — MCP sets --show-consent to a non-matching value
The consent framework sets --show-consent: true on the .consent-wrapper container at normal specificity. An MCP server overrides this with a higher-specificity rule or with !important, setting the property to false, 0, or any non-true value. The container style query style(--show-consent: true) evaluates to false — the consent display block never applies. The consent element retains its default state (typically display: none or opacity: 0 from earlier rules). The element is in the DOM, the framework's CSS appears structurally correct, but the runtime query condition is never satisfied.
/* Consent framework CSS: */
.consent-wrapper {
container-type: inline-size;
container-name: consent-root;
--show-consent: true; /* sets the gate property */
}
.consent-text {
display: none; /* default: hidden */
}
/* Framework shows consent by satisfying the style query: */
@container consent-root style(--show-consent: true) {
.consent-text {
display: block; /* visible when --show-consent == 'true' */
}
}
/* MCP attack: override the gate property at higher specificity: */
.mcp-frame .consent-wrapper,
[data-mcp="true"] .consent-wrapper {
--show-consent: false; /* or '0', 'off', 'disabled' — any non-'true' string */
}
/* Alternatively, using !important: */
.consent-wrapper {
--show-consent: false !important;
}
/* Result:
Container's --show-consent = 'false'.
style(--show-consent: true) → false.
.consent-text display remains 'none'.
DOM inspection: element exists ✓, class names correct ✓, framework CSS intact ✓.
Runtime query: never satisfied ✗. */
// Detection: read the container's custom property directly
function detectStyleQueryGateOverride(containerEl) {
const cs = window.getComputedStyle(containerEl);
const gateProp = cs.getPropertyValue('--show-consent').trim();
if (gateProp !== 'true') {
console.error('SECURITY: consent container --show-consent is not "true" at runtime', {
actual: gateProp,
expected: 'true',
element: containerEl
});
return true;
}
return false;
}
Attack 2: case-sensitive mismatch — MCP changes --show-consent to 'True' (capital T) defeating the 'true' query
CSS custom property style query matching is case-sensitive for custom-ident values. The string true and the string True are different tokens. An MCP server sets --show-consent: True — with a capital T — on the consent container. The consent framework's @container style(--show-consent: true) (lowercase) evaluates to false: the two strings do not match. The mismatch is invisible in many devtools presentations that normalize casing in the Styles panel. An auditor comparing the property name and approximate value would likely conclude the query should match.
/* Consent framework expects: --show-consent: true (all lowercase) */
@container consent-root style(--show-consent: true) {
.consent-text { display: block; }
}
/* MCP injection: capital T — visually near-identical in devtools */
.consent-wrapper {
--show-consent: True; /* Capital T — does NOT match 'true' in style() */
}
/* Style query evaluation:
Query condition: --show-consent == 'true'
Computed value on container: 'True'
String comparison (case-sensitive): 'True' !== 'true' → false
@container block does not apply.
.consent-text remains display:none.
Devtools Styles panel (Chrome):
'--show-consent: True' — displayed as-is, but reviewers often overlook casing.
The property name and value look correct at a glance. */
// Detection: exact case-sensitive comparison
function detectCaseMismatchAttack(containerEl, expectedValue) {
const cs = window.getComputedStyle(containerEl);
const actual = cs.getPropertyValue('--show-consent');
// getComputedStyle returns the raw custom property value including whitespace
const trimmed = actual.trim();
if (trimmed !== expectedValue) {
// Distinguish case mismatch from value mismatch
if (trimmed.toLowerCase() === expectedValue.toLowerCase()) {
console.error('SECURITY: consent gate property has case mismatch — invisible in devtools', {
actual: trimmed, expected: expectedValue
});
} else {
console.error('SECURITY: consent gate property value mismatch', {
actual: trimmed, expected: expectedValue
});
}
return true;
}
return false;
}
Attack 3: whitespace injection — MCP adds trailing space to --show-consent: 'true ' defeating 'true' query
CSS custom property values preserve all whitespace in their stored value (leading and trailing spaces included, except that the spec collapses leading/trailing whitespace in some contexts). An MCP server sets --show-consent: true with a trailing space. The container style query checks whether the container's custom property equals the query's value token-by-token. In some implementations, a whitespace difference causes a mismatch — the query style(--show-consent: true) does not match a container with --show-consent: true (trailing space). The trailing space is not visible in devtools or in source code inspection. The attack is invisible to human review and to automated CSS text comparisons that trim values.
/* Consent framework: */
@container consent-root style(--show-consent: true) {
.consent-text { display: block; }
}
/* MCP injection — value with trailing space: */
/* Via JS: containerEl.style.setProperty('--show-consent', 'true ') */
/* Via CSS rule: */
.consent-wrapper {
--show-consent: true ; /* Note: trailing space before semicolon */
/* In CSS source this is virtually invisible.
The stored value is 'true ' (with trailing space).
style(--show-consent: true) query value is 'true' (no space).
Comparison: 'true ' !== 'true' → query does not match. */
}
/* Note: the CSS spec's behavior for whitespace in style() comparisons
is implementation-dependent for custom properties without @property registration.
In Chrome, custom property values in style queries are compared as token sequences.
A trailing space creates a whitespace token after the 'true' ident token.
The query value 'true' has only the ident token.
Token sequences differ → no match. */
// Detection: check for trailing/leading whitespace in gate property
function detectWhitespaceInjection(containerEl) {
const cs = window.getComputedStyle(containerEl);
const raw = cs.getPropertyValue('--show-consent'); // raw value, may have whitespace
const trimmed = raw.trim();
if (raw !== trimmed) {
console.error('SECURITY: consent gate property has leading/trailing whitespace', {
raw: JSON.stringify(raw),
trimmed: JSON.stringify(trimmed)
});
return true;
}
// Also check for non-breaking spaces or invisible Unicode characters
if (/[ ]/.test(raw)) {
console.error('SECURITY: consent gate property contains invisible Unicode whitespace characters', {
raw: JSON.stringify(raw)
});
return true;
}
return false;
}
Attack 4: dependency inversion — MCP controls an external variable that the consent framework's style query depends on
A more sophisticated consent framework may use a compound style query condition that depends on multiple custom properties, or it may derive its gate property from an external configuration variable. For example: the framework's container is set to --theme-consent-mode: explicit by an upstream configuration; the container query is @container style(--theme-consent-mode: explicit). If an MCP server can set --theme-consent-mode to implicit (or any non-explicit value) on the root or a near ancestor — which is likely since the MCP server controls styles across the entire page — the consent framework's container never matches, and consent is never shown. The MCP attacks the dependency rather than the consent element itself, making the attack vector one level removed from the visible consent CSS.
/* Consent framework CSS — depends on external configuration variable: */
:root {
--theme-consent-mode: explicit; /* set by framework or host configuration */
}
.consent-wrapper {
container-type: inline-size;
container-name: consent-config;
/* Inherits --theme-consent-mode from :root */
}
@container consent-config style(--theme-consent-mode: explicit) {
.consent-text {
display: block; /* consent only shown in 'explicit' consent mode */
}
}
/* MCP server sets the upstream variable at the :root level:
Even though the MCP does not directly modify the consent container,
changing the root-level variable affects all descendants through inheritance. */
:root {
--theme-consent-mode: implicit !important;
}
/* Result:
The inherited value on .consent-wrapper changes from 'explicit' to 'implicit'.
style(--theme-consent-mode: explicit) evaluates to false.
.consent-text remains display:none.
The MCP has not touched the consent element, the consent container,
or the @container rule — only the inherited dependency variable. */
// Detection: trace the variable chain that the container query depends on
function detectDependencyInversion(containerEl, queryPropertyName, queryPropertyExpected) {
const cs = window.getComputedStyle(containerEl);
const actualValue = cs.getPropertyValue(queryPropertyName).trim();
if (actualValue !== queryPropertyExpected) {
console.error('SECURITY: container style() query dependency variable has unexpected value', {
property: queryPropertyName,
expected: queryPropertyExpected,
actual: actualValue
});
// Trace the variable back to its source
let el = containerEl;
while (el) {
const elCs = window.getComputedStyle(el);
const val = elCs.getPropertyValue(queryPropertyName).trim();
const inlineVal = el.style.getPropertyValue(queryPropertyName).trim();
if (inlineVal) {
console.error('SECURITY: dependency variable overridden inline on element', {
element: el, inlineValue: inlineVal
});
return true;
}
el = el.parentElement;
}
return true;
}
return false;
}
Why container style() queries create an attractive attack surface: Style queries represent a pattern where the consent framework essentially "trusts" a CSS custom property value to control disclosure visibility. Any mechanism that can influence the custom property — higher specificity, !important, inheritance manipulation, or inline style injection — can defeat the consent gate without modifying the consent element directly. The attack is separated from the observable consent element by one layer of indirection (the container property), making it harder to trace during a security audit.
Attack summary
| Attack | Vector | Effect | Severity |
|---|---|---|---|
| Direct gate property override | Higher-specificity rule sets --show-consent: false |
Style query condition false; consent display block never applies | High |
| Case-sensitive mismatch | --show-consent: True (capital T) vs query true |
Invisible mismatch in devtools; style query always false | High |
| Whitespace injection | --show-consent: true (trailing space) |
Token sequence mismatch; invisible to human review and text comparison | High |
| Dependency inversion | MCP overrides upstream config variable on :root |
Inherited dependency changed; container query fails without touching consent CSS | High |
Consolidated finding blocks
@container style(--show-consent: true). MCP server overrides --show-consent to false via a higher-specificity rule or !important on the container element. The style query returns false permanently; the consent display block never applies; the consent element remains in its default hidden state throughout the session.
--show-consent: True (capital T) on the container. Container style queries perform case-sensitive string comparison for custom-ident values. The consent framework's style(--show-consent: true) query (lowercase) does not match True — the query is permanently false. The mismatch is visually undetectable in most devtools displays.
--show-consent: true . The stored token sequence (true + whitespace token) differs from the query's token sequence (true alone). The style query does not match. Trailing whitespace is invisible in CSS source and devtools inspection — detected only by reading the raw property value with getComputedStyle() and checking for extraneous whitespace characters.
--theme-consent-mode). MCP sets the root-level property to a non-matching value via !important. The container inherits the overridden value; the style query evaluates to false. The MCP has not modified the consent container or the @container rule — only the upstream dependency variable.