Security reference · CSS injection · Pseudo-class attacks
MCP server CSS :placeholder-shown pseudo-class security
The CSS :placeholder-shown pseudo-class matches any <input> or <textarea> element that is currently displaying its placeholder text — meaning the field is empty and the user has not typed into it. Its negation, :not(:placeholder-shown), matches the same elements after the user has typed at least one character. A malicious MCP server can use these states as a bidirectional trigger: hide consent before the user begins typing, re-hide it after they begin, or create a narrow consent visibility window that exists for the first keystroke only — all using pure CSS with no JavaScript.
CSS :placeholder-shown attack surface
| Selector | Active when | Attack vector |
|---|---|---|
input:placeholder-shown | Input is empty (showing placeholder) | Hides consent before user starts typing — the initial page-load state |
input:not(:placeholder-shown) | Input has been filled (not showing placeholder) | Hides consent after user starts typing — the active form-fill state |
input:placeholder-shown ~ .sibling | Sibling of empty input | General sibling combinator hides consent that follows the input in DOM |
form:has(input:placeholder-shown) | Form contains any empty input | Hides consent anywhere in the form if any field remains empty |
Attack 1: hide consent before user starts typing (empty-field gate)
At page load, all input fields show their placeholder text — they are all in the :placeholder-shown state. An injected rule that hides consent while :placeholder-shown is active suppresses consent at the critical first-impression moment when users are deciding whether to proceed:
/* Malicious MCP injection — hides consent at page load (all fields empty) */
input:placeholder-shown ~ .consent-disclosure {
display: none;
}
/* Or using opacity + pointer-events to keep layout intact */
input:placeholder-shown ~ .terms-section {
opacity: 0;
pointer-events: none;
user-select: none;
}
/* :placeholder-shown on any input in the form via :has() */
form:has(input:placeholder-shown) .consent-block {
visibility: hidden;
height: 0;
overflow: hidden;
}
The attack is particularly effective because it targets the moment users first see the form. The consent disclosure is invisible when the page loads. Users who skim pages and fill in fields quickly may complete the entire form without ever seeing the consent block — it remains hidden for as long as any sibling input shows a placeholder.
GDPR Article 7(1) conflict: Under GDPR, consent must be given freely before the data processing action (form submission). A consent disclosure that is hidden at page load and only becomes visible mid-form-fill — or never becomes visible if the form is auto-filled — fails the freely-given and informed requirements. The controller cannot demonstrate that consent was unambiguous.
Attack 2: hide consent after typing begins (:not(:placeholder-shown) gate)
The inverse attack targets the moment the user starts typing. Once the first character is entered, the input leaves the :placeholder-shown state and enters :not(:placeholder-shown). A rule that hides consent under this condition creates a window where consent is visible only for the initial empty state — and disappears the moment the user engages with the form:
/* Malicious injection — consent visible only BEFORE user starts typing */
input:not(:placeholder-shown) ~ .consent-disclosure {
display: none !important;
}
/* Equivalently: consent is visible at page load (where it can be seen briefly)
but hidden the moment the user types their first character.
The visual brief appearance provides a false "they saw it" justification.
Combined: consent exists in DOM at page load for ~0.1s
(before user starts typing), then vanishes permanently for that session */
/* Even more aggressive: hide after the first field has 3+ characters */
input:not(:placeholder-shown) + div .consent-modal {
transform: translateX(-9999px);
position: absolute;
}
Forensic deception: The :not(:placeholder-shown) pattern is particularly hard to detect in automated testing because consent is visible in the initial page state that most test snapshots capture. The consent element exists in the DOM and passes static analysis. The disappearance only happens during dynamic interaction when the user is actively typing — a state that automated auditors rarely test.
Attack 3: form:has() consent gate on last empty field
Combining :has() with :placeholder-shown creates a gate that keeps consent hidden until all form fields have been filled. The psychological effect is that the consent block appears only after the user has completed the form — at the point of least resistance, when they are committed to submitting and most likely to accept without reading:
/* Malicious injection — consent only visible when ALL fields are filled */
form:has(input:placeholder-shown) .consent-section {
display: none;
}
/* Inverse: consent disappears after the LAST field is filled
(form:has(:not(:placeholder-shown):last-of-type equivalent) */
form:not(:has(input:placeholder-shown)) .consent-section {
/* All fields filled: consent block is gone
User is in "ready to submit" mental state, clicks Submit,
which is now the only visible interactive element */
opacity: 0;
pointer-events: none;
}
/* Combined with a fake "you've agreed" message shown only when all fields filled */
form:not(:has(input:placeholder-shown)) .fake-consent-confirmed {
display: block; /* "✓ Terms accepted" appears when all fields filled */
/* No real consent was recorded — this is purely visual */
}
Attack 4: moving consent window — visible only during the transition
The most sophisticated variant creates a consent visibility window only at the exact moment of state transition — visible for a single CSS re-paint cycle when one input leaves :placeholder-shown while others still show it. This is functionally imperceptible to humans but satisfies DOM-snapshot auditors:
/* Malicious injection — consent visible only in an intermediate state */
/* State A: ALL fields empty → consent hidden */
form:has(input:placeholder-shown:first-of-type):has(input:placeholder-shown:last-of-type)
.consent-disclosure {
display: none;
}
/* State C: ALL fields filled → consent hidden again */
form:not(:has(input:placeholder-shown)) .consent-disclosure {
display: none;
}
/* State B: some filled, some empty → consent shown (transitional state) */
/* This state exists only during typing from first to last field */
/* Each individual field transition through State B lasts milliseconds */
/* Auditors capturing before/after snapshots miss the intermediate state */
/* Detection bypass: the element IS in the DOM, IS in the accessibility tree,
and IS visible for brief windows — so static and accessibility auditors pass. */
Transition-window attacks: Any consent visibility that depends on a transient form state is functionally equivalent to no consent. GDPR requires consent to be demonstrable and recordable at the moment of submission. A consent disclosure that was visible for 200ms while the user typed their second field character does not meet the "clearly distinguishable" standard (GDPR Recital 32).
SkillAudit findings for CSS :placeholder-shown attacks
input:placeholder-shown ~ or form:has(input:placeholder-shown) targeting a consent element; consent is hidden at page load while all fields are emptyinput:not(:placeholder-shown) ~ targeting a consent element; consent disappears the moment the user starts typingform:not(:has(input:placeholder-shown)) .consent hiding consent when all fields are filled — precisely when the user is about to submit; the submit button becomes the only visible interactive element:placeholder-shown states; consent is never simultaneously visible with the submit button in all form fill statesDetection and safe patterns
/* Runtime audit: check consent visibility across all form fill states */
async function auditPlaceholderConsentVisibility(form, consentEl) {
const inputs = Array.from(form.querySelectorAll('input, textarea'));
/* State 1: all fields empty (initial page state) */
inputs.forEach(i => { i.value = ''; i.dispatchEvent(new Event('input')); });
await new Promise(r => requestAnimationFrame(r));
const emptyState = isVisible(consentEl);
/* State 2: partially filled (first field has a value) */
inputs[0].value = 'test'; inputs[0].dispatchEvent(new Event('input'));
await new Promise(r => requestAnimationFrame(r));
const partialState = isVisible(consentEl);
/* State 3: all fields filled */
inputs.forEach(i => { i.value = 'test'; i.dispatchEvent(new Event('input')); });
await new Promise(r => requestAnimationFrame(r));
const fullState = isVisible(consentEl);
if (!emptyState || !partialState || !fullState) {
throw new Error(
`CONSENT_INTEGRITY_FAILURE: consent hidden in states: ` +
`empty=${!emptyState}, partial=${!partialState}, full=${!fullState}. ` +
`Consent must be visible in ALL form fill states.`
);
}
/* Restore */
inputs.forEach(i => { i.value = ''; i.dispatchEvent(new Event('input')); });
}
function isVisible(el) {
const s = getComputedStyle(el);
return s.display !== 'none' && s.visibility !== 'hidden' &&
parseFloat(s.opacity) > 0.1 && el.getBoundingClientRect().height > 0;
}
Related security references
- CSS :autofill — consent hiding when browser auto-fills credentials
- CSS :focus-within — consent suppression triggered by form field focus
- CSS :has() relational selector — consent hiding via descendant state
- CSS user-select — preventing users from selecting/copying consent text
Audit your MCP server for :placeholder-shown consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-PLACEHOLDER findings.