Security reference · CSS injection · appearance · Consent manipulation
MCP server CSS appearance consent security — appearance:none checkbox invisible attack
CSS appearance: none strips the browser's OS-native rendering of form controls. On a consent checkbox, the result is an element that exists fully in the DOM and the accessibility tree, can receive clicks and update its checked state, and passes form validation — but has zero rendered visual presence without explicit replacement CSS. On an install button, it removes every visual affordance: border, background, padding, and cursor change. On a consent dropdown, it hides the native select arrow and styling, enabling a fake custom dropdown to overlay hidden consent option values the user never sees.
appearance attack surface in MCP consent forms
| Attack ID | Target element | What appearance:none removes | What the user sees | DOM/functional state |
|---|---|---|---|---|
| SA-CSS-AP-001 | <input type="checkbox"> | Checkbox box, check glyph, border, background, dimensions | Nothing (zero visual size without replacement CSS) | Element in DOM, clickable, checked state updates, passes validation |
| SA-CSS-AP-002 | <button> install button | Border, background, padding, cursor:pointer change | Plain text that looks like a paragraph sentence | Button element, fully clickable, executes onclick |
| SA-CSS-AP-003 | <select> consent dropdown | Dropdown arrow, native select styling | Custom visual dropdown (fake overlay) | Native select captures value; hidden options set consent state |
appearance:none is the starting point for legitimate custom form control design: Most custom checkbox implementations use appearance: none as step one before adding their own CSS. This means a consent checkbox with appearance: none plus complete replacement CSS is a normal, legitimate pattern. The attack is specifically the combination of appearance: none without adequate replacement CSS — leaving the element visually absent. Audits must verify both the appearance value and whether the element has visible dimensions, a border or background, and sufficient contrast after the OS rendering is stripped.
Attack 1: appearance: none on consent checkbox — invisible but fully functional (SA-CSS-AP-001)
A consent <input type="checkbox"> relies on the OS's native form-control rendering to display the checkbox box, checkmark, border, and background. When appearance: none (or the prefixed -webkit-appearance: none) is applied, all OS rendering is removed. Without explicit CSS specifying width, height, border, and background, the checkbox becomes invisible — it has no visual bounding box. In older Safari and some Chromium versions, the checkbox collapses to zero size entirely. The input element continues to function: it remains in the accessibility tree (announced as "checkbox, unchecked"), its checked property updates correctly, it accepts programmatic click() calls, and form validation reads its state. MCP JavaScript programmatically checks the invisible box at install time, satisfying any consent-gating form logic without any user action.
/* Malicious CSS — SA-CSS-AP-001 */
.mcp-consent-checkbox {
appearance: none;
-webkit-appearance: none; /* Safari / older Chromium */
-moz-appearance: none; /* Firefox */
/* NO replacement CSS provided:
No width, height, border, background, or outline.
Without these, the checkbox has:
- Zero rendered size in Safari (collapses completely)
- A ~1px invisible presence in Chrome/Firefox (too small to see or interact with)
- No visible checked state indicator
The :checked pseudo-class has no visual change because appearance:none removed
all the native check-glyph rendering and no replacement ::before/::after is defined. */
}
/* Compare with a legitimate appearance:none pattern (what honest usage looks like): */
.legitimate-custom-checkbox {
appearance: none;
-webkit-appearance: none;
width: 18px; /* explicit dimension */
height: 18px; /* explicit dimension */
border: 2px solid #888; /* visible border replaces OS-native box */
border-radius: 3px;
background: white;
cursor: pointer;
}
.legitimate-custom-checkbox:checked {
background: #2563eb; /* explicit checked state */
border-color: #2563eb;
}
.legitimate-custom-checkbox:checked::after {
content: '✓'; /* explicit checkmark replaces OS-native check glyph */
color: white;
font-size: 12px;
}
/* The attack is appearance:none WITHOUT this replacement CSS block */
/* ARIA and accessibility implications:
- The checkbox is still in the accessibility tree
- VoiceOver/NVDA announces it as "checkbox" with correct state
- An accessibility audit finds nothing wrong
- Only a visual/rendered DOM audit finds the invisible element
- Screen reader users who rely on keyboard navigation can Tab to it
but cannot see whether it is checked or unchecked */
/* MCP install-time JS — programmatic check */
document.querySelector('.mcp-install-form').addEventListener('submit', (e) => {
e.preventDefault();
const cb = document.querySelector('.mcp-consent-checkbox');
/* User cannot interact with invisible checkbox — it was never checked */
cb.checked = true; /* programmatically check it to pass form validation */
if (cb.checked) {
initiateInstall();
}
});
/* Detection: */
function detectInvisibleCheckbox() {
const findings = [];
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
for (const cb of checkboxes) {
/* Is this in a consent context? */
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
const isConsentCheckbox = CONSENT.test(
cb.getAttribute('aria-label') || cb.id ||
document.querySelector(`label[for="${cb.id}"]`)?.textContent || ''
) || CONSENT.test(cb.closest('[class*="consent"], [class*="disclosure"]')
?.textContent?.substring(0, 400) || '');
if (!isConsentCheckbox) continue;
const s = getComputedStyle(cb);
const appearance = s.appearance || s.webkitAppearance || s.mozAppearance || '';
/* Check 1: appearance:none applied */
if (appearance !== 'auto' && appearance !== 'checkbox') {
/* appearance:none detected — now verify replacement CSS is present */
const hasWidth = parseFloat(s.width) > 4; /* >4px width */
const hasHeight = parseFloat(s.height) > 4; /* >4px height */
const hasBorder = s.borderTopWidth !== '0px' ||
s.outlineWidth !== '0px' ||
s.boxShadow !== 'none';
const hasBackground = s.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
s.backgroundColor !== 'transparent';
if (!hasWidth || !hasHeight || (!hasBorder && !hasBackground)) {
findings.push({
id: 'SA-CSS-AP-001',
severity: 'critical',
element: cb,
computedAppearance: appearance,
computedWidth: s.width,
computedHeight: s.height,
message: `Consent checkbox has appearance:${appearance} without adequate replacement CSS (width:${s.width}, height:${s.height}, border:${s.borderTopWidth}, background:${s.backgroundColor}). The checkbox is invisible or near-invisible. User cannot see or interact with it; programmatic check at install time bypasses informed consent.`
});
}
}
/* Check 2: element has zero bounding rect */
const rect = cb.getBoundingClientRect();
if (rect.width < 5 || rect.height < 5) {
findings.push({
id: 'SA-CSS-AP-001',
severity: 'critical',
element: cb,
boundingRect: rect,
message: `Consent checkbox has rendered size ${rect.width}x${rect.height}px — effectively invisible. User cannot meaningfully interact with this checkbox to express consent.`
});
}
}
return findings;
}
Attack 2: appearance: none on install button — button disguised as plain text (SA-CSS-AP-002)
Browser-default button styling provides critical affordances that signal interactivity: a raised or bordered box, a distinct background color, a cursor: pointer change on hover, and padding separating the button text from surrounding content. appearance: none on a <button> element removes all of these OS-native affordances. Combined with color: inherit and background: transparent, the button becomes visually indistinguishable from a paragraph of surrounding text. The button element exists in the DOM and executes its click handler normally — a user who clicks the button-text launches the install. The consent bypass works because users do not perceive the install action as something they are actively triggering; they may believe they are clicking a link or reading highlighted text. The install fires without the user making a conscious "I am clicking the install button" decision.
/* Malicious CSS — SA-CSS-AP-002 */
.mcp-install-trigger {
/* appearance:none removes all browser-default button affordances */
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
/* Additional properties to complete the disguise */
background: transparent; /* no button background */
border: none; /* no button border */
padding: 0; /* no button padding */
margin: 0; /* flush with surrounding text */
font-size: inherit; /* same size as surrounding paragraph */
font-family: inherit; /* same typeface as surrounding paragraph */
color: inherit; /* same color as surrounding text — not styled as a link */
cursor: default; /* no pointer cursor change on hover */
display: inline; /* renders inline like a span or em, not as a block button */
text-align: inherit; /* matches surrounding text alignment */
}
/* Result: the install button renders identically to a or inline text node.
A user scanning the page sees:
"By continuing you agree to the terms of service. Click here to install
the SkillAudit MCP server and grant it access to your filesystem."
Where "here" is the disguised button and clicking anywhere in that sentence
within the button's click area triggers the install.
The button may wrap a longer sentence that itself sounds like consent copy:
Clicking what appears to be a consent acknowledgement statement executes the install. */
/* Why this bypasses consent intent:
The user believes they are:
a) Reading the consent text (not clicking anything)
b) Clicking a text link for more information
c) Acknowledging a statement (not initiating an install)
None of these perceptions matches the actual effect: installing the MCP server. */
/* ARIA implications:
- The button has role="button" in the accessibility tree
- Screen readers announce it as "button" — keyboard users know it's a button
- ONLY keyboard/screen reader users are protected; visual users are deceived
- An accessibility audit would flag missing visual affordance as an a11y bug,
not a security issue, and might not catch the consent bypass angle */
/* Detection: */
function detectDisguisedButton() {
const findings = [];
const INSTALL = /install|accept|proceed|confirm|grant.*access|continue.*install/i;
const buttons = document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]');
for (const btn of buttons) {
if (!INSTALL.test(btn.textContent || btn.value || btn.getAttribute('aria-label') || '')) continue;
const s = getComputedStyle(btn);
const appearance = s.appearance || s.webkitAppearance || '';
/* Check appearance:none */
const appearanceStripped = appearance === 'none';
/* Check visual affordances */
const hasBorder = s.borderTopWidth !== '0px' && s.borderTopWidth !== '';
const hasBackground = s.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
s.backgroundColor !== 'transparent';
const hasOutline = s.outlineWidth !== '0px' && s.outlineStyle !== 'none';
const hasCursorPointer = s.cursor === 'pointer';
const hasPadding = parseFloat(s.paddingTop) > 0 || parseFloat(s.paddingLeft) > 0;
/* Count how many visual affordances are present */
const affordances = [hasBorder, hasBackground, hasOutline, hasCursorPointer, hasPadding]
.filter(Boolean).length;
if (appearanceStripped && affordances <= 1) {
/* appearance stripped and at most one affordance remaining */
findings.push({
id: 'SA-CSS-AP-002',
severity: 'high',
element: btn,
computedAppearance: appearance,
affordancesPresent: affordances,
message: `Install button "${btn.textContent?.substring(0, 60)}" has appearance:none with ${affordances}/5 visual affordances (border:${hasBorder}, background:${hasBackground}, outline:${hasOutline}, cursor-pointer:${hasCursorPointer}, padding:${hasPadding}). The button is visually indistinguishable from surrounding text. Users may not perceive it as an interactive install trigger.`
});
}
}
return findings;
}
/* Behavioral check: verify button is visually distinguishable from adjacent text nodes */
function checkButtonVisualContrast(btn) {
const s = getComputedStyle(btn);
const parentS = getComputedStyle(btn.parentElement);
const sameColor = s.color === parentS.color;
const sameFont = s.fontSize === parentS.fontSize && s.fontWeight === parentS.fontWeight;
const noBackground = s.backgroundColor === 'rgba(0, 0, 0, 0)' || s.backgroundColor === 'transparent';
const noBorder = s.borderTopWidth === '0px';
if (sameColor && sameFont && noBackground && noBorder) {
return {
id: 'SA-CSS-AP-002',
severity: 'high',
message: 'Install button is visually identical to its parent text content — no color, font, background, or border difference. Cannot be distinguished from surrounding paragraph text.'
};
}
return null;
}
The disguised button as a "dark pattern consent statement": The most dangerous SA-CSS-AP-002 variant wraps the button text in affirmative consent language: <button class="mcp-install-trigger">I agree to grant access to my filesystem and contacts</button>. Styled as plain text, the user reads what appears to be a consent disclosure statement. Clicking anywhere in that sentence — which they may do to select text, to dismiss a double-click, or accidentally — executes the install with full "consent" language available in the DOM for any log or audit. The DOM record shows the user "clicked" a consent statement; the visual reality is that the statement was indistinguishable from surrounding paragraph text.
Attack 3: appearance: none on <select> + fake overlay — hidden consent option values (SA-CSS-AP-003)
Custom dropdown menus are a standard web pattern: the native <select> is hidden or stripped with appearance: none, and a visually styled replacement is created with <div> and JavaScript. The attack exploits the gap between the visible replacement and the hidden native element. appearance: none strips the native dropdown arrow and styling. A custom visual dropdown is overlaid with pointer-events: none — meaning clicks on the visual dropdown pass through to the native <select> underneath. The native select's <option> elements contain consent values the user cannot see because the visual overlay shows different text. The user believes they are selecting from the visual options; they are actually selecting from the hidden native options, which may include pre-selected consent grants.
/* Malicious CSS — SA-CSS-AP-003 */
/* Step 1: strip native select rendering with appearance:none */
.mcp-consent-select-native {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
opacity: 0; /* invisible */
appearance: none; /* strip native dropdown arrow and styling */
-webkit-appearance: none;
-moz-appearance: none;
pointer-events: auto; /* receives click events (this is the real select) */
z-index: 2; /* above the visual overlay in hit-testing */
cursor: pointer; /* appears to be clickable */
}
/* Step 2: custom visual overlay — what the user sees */
.mcp-consent-select-visual {
position: relative;
width: 100%;
height: 40px;
border: 1px solid #444;
border-radius: 4px;
padding: 0 12px;
display: flex;
align-items: center;
background: var(--bg-alt);
pointer-events: none; /* clicks pass THROUGH to the native select underneath */
z-index: 1;
}
/* The container holds both: */
.mcp-consent-select-wrapper {
position: relative;
display: block;
}
/* HTML structure:
The visual dropdown shows: "Read only access" (as if that is the selection)
The native select has value="write" (full access) pre-selected.
When the user "opens" the dropdown by clicking what they think is the visual control:
- Their click passes through the visual overlay (pointer-events:none) to the native select
- The native select opens its own OS dropdown with options the user can now see —
but the visual overlay overlaid those options with the custom styled list
- In some mobile browsers and certain desktop configs, the native select opens
behind or partially covered by the visual overlay
- Most users assume the visible label "Read only access" matches what is selected
The form submits with value="write" — full access — not value="read". */
/* Why appearance:none is central:
Without appearance:none, the native select renders its OS-native arrow and border.
This creates a double-arrow visual artifact (native arrow + visual arrow from the overlay).
appearance:none removes the native arrow, making the overlay-on-select combo look clean.
The absence of visual artifacts is what makes this attack look like legitimate custom styling. */
/* Detection: */
function detectFakeDropdown() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|access.*level|permission.*scope/i;
const selects = document.querySelectorAll('select');
for (const sel of selects) {
/* Is this in a consent context? */
const isConsentSelect = CONSENT.test(sel.getAttribute('aria-label') || '') ||
CONSENT.test(sel.closest('[class*="consent"], [class*="permission"], [class*="scope"]')
?.textContent?.substring(0, 500) || '') ||
Array.from(sel.options).some(o => CONSENT.test(o.text || o.value || ''));
if (!isConsentSelect) continue;
const s = getComputedStyle(sel);
const appearance = s.appearance || s.webkitAppearance || '';
const isHidden = s.opacity === '0' || s.visibility === 'hidden' || parseFloat(s.opacity) < 0.1;
/* Check 1: appearance:none on a consent select (legitimate OR attack) */
if (appearance === 'none') {
findings.push({
id: 'SA-CSS-AP-003',
severity: 'medium',
element: sel,
message: `Consent
Legitimate custom dropdowns use the same CSS pattern: appearance: none on a <select> combined with a visual overlay is the standard technique for custom-styled form controls. The attack is distinguishable only by verifying that: (1) the native select's <option> values match what the visual replacement shows, (2) no hidden options with consent-granting values are pre-selected, and (3) the visual replacement shows the selected option's text accurately. SkillAudit performs all three checks as part of the SA-CSS-AP-003 detection pass.
SkillAudit detection for CSS appearance consent attacks
<input type="checkbox"> has appearance: none (or prefixed variants) without adequate replacement CSS defining visible width, height, border, and checked-state indicator. The checkbox has zero or near-zero rendered size, making it invisible to users while remaining a functional DOM element. JavaScript programmatically sets checked = true at install time. SkillAudit checks computed appearance values on all consent checkboxes and verifies the checkbox has a visible bounding rect (>5x5px), a visible border or background, and a visible :checked state indicator.
appearance: none combined with transparent background, zero border, default (non-pointer) cursor, zero padding, and inherited font and color properties. The button is visually indistinguishable from surrounding paragraph text. Users clicking what appears to be a consent statement or plain text are triggering the install. SkillAudit scores install button visual affordances after detecting appearance: none and flags buttons with fewer than 2 of 5 affordance signals present (border, background, outline, cursor-pointer, padding).
<select> has appearance: none and is visually hidden (opacity near 0), overlaid by a custom visual dropdown with pointer-events: none. The native select contains option values that are absent from the visual replacement. The currently selected option's text does not appear in the visual UI. The form submits a consent-level value the user has never seen. SkillAudit compares native <option> text values against sibling visual element text content to detect hidden options in consent selects.
<select> with appearance: none has a visual replacement showing a different selection label than the native select's selectedIndex text. The submitted form value does not match the user's visible selection. SkillAudit reads the visual selected-option element's text and the native select's options[selectedIndex].text and flags discrepancies in consent-context selects.
Run SkillAudit to detect SA-CSS-AP patterns in any MCP server before install. SkillAudit's consent audit checks computed appearance values on all consent-area form controls, verifies checkbox bounding rect dimensions, scores install button visual affordances, and performs native-vs-visual option value comparison on all custom dropdown patterns in the install flow.
Related MCP consent attack research
- CSS pointer-events attacks — disable consent checkbox interaction and intercept clicks
- CSS opacity attacks — direct opacity:0 hiding and JS-deferred variants
- CSS visibility attacks — visibility:hidden and collapse variants
- CSS z-index stacking order attacks — consent buried behind overlapping elements
- CSS exfiltration security in MCP servers