Security Guide
MCP server CSS :visited pseudo-class security — browsing history exploited to manipulate consent state perception
Modern browsers restrict :visited styling to a small set of color properties to prevent history leakage. But within those permitted properties, an MCP server can craft consent dialogs where the :visited state of links creates misleading visual impressions — making a user believe they have already reviewed and accepted terms they have not. A terms link styled with :visited { color: green } reads as "done" in the user's visual vocabulary, reducing the probability that they re-read the terms before accepting.
How :visited restrictions work and what remains exploitable
Before 2010, CSS :visited allowed arbitrary styling differences between visited and unvisited links. Scripts could read these differences via getComputedStyle and reconstruct the user's browsing history from the visual state of a constructed link set — a serious privacy attack. In response, browsers implemented a "privacy-preserving :visited" model:
- Permitted properties:
color,background-color,border-top-color,border-right-color,border-bottom-color,border-left-color,outline-color, and fill/stroke on SVG elements. - Blocked: All layout properties (width, height, margin, padding), visibility, font-size, font-weight, opacity, transform, background-image, box-shadow, content.
- Script mitigation:
getComputedStyle(el, ':visited')returns the:linkvalues, not the actual :visited computed values, to prevent JavaScript history sniffing.
What remains: the browser does visually apply the permitted color changes to visited links. A user can see that a link appears in a different color. The attack surface is not scripted history leakage — it is the visual semantic exploitation of the :visited color change in consent UX design.
/* What :visited CSS is restricted to (all browsers, post-2010): */
a:visited {
color: green; /* PERMITTED — visual color change */
background-color: #d4edda; /* PERMITTED — background color change */
border-color: green; /* PERMITTED — border color change */
outline-color: green; /* PERMITTED — outline color change */
}
/* What :visited CSS cannot do (blocked by browser privacy model): */
a:visited {
display: none; /* BLOCKED — layout change */
font-size: 0; /* BLOCKED — layout change */
opacity: 0; /* BLOCKED — transparency */
visibility: hidden; /* BLOCKED — visibility */
content: "✓ Read"; /* BLOCKED — content replacement */
}
/* The permitted styling is still a significant attack surface
in consent dialogs where color conveys approval state.
Green ≈ approved. Purple/muted ≈ read/acknowledged.
These color semantics are deeply culturally established. */
Consent state color semantics: In standard UI design, green = success/approved, gray = disabled/done, purple = visited/read. A consent dialog where the terms link changes to green on visit (via :visited { color: green }) creates a visual signal that is semantically equivalent to "this step is complete." Users habituated to green checkmarks and "step completed" indicators interpret the color change as a positive confirmation — reducing their perceived need to read the linked terms carefully.
Attack 1 (HIGH): Terms link :visited green — "already reviewed" false impression
The most common exploit: a consent dialog contains a link to the Terms of Service ("Read our Terms"). The MCP server styles this link with :visited { color: #28a745; background-color: rgba(40,167,69,0.1) } — the standard Bootstrap "success" green. If the user has previously visited the terms page (e.g., during account creation, or via a prior session on the same domain), the link appears green — visually matching the "completed" state in the consent UI. The user sees a green terms link, interprets it as "I already read this," and accepts the consent without re-reading the updated or new terms. The MCP server may have changed the terms content since the user's last visit; the green link implies the user is up to date when they are not.
/* Attack 1: Terms link styled green on visit — implies "reviewed and done" */
.consent-dialog a.terms-link {
color: #4a5568; /* Unvisited: muted gray — "pending action" */
text-decoration: underline;
}
.consent-dialog a.terms-link:visited {
color: #28a745; /* Visited: success green — "completed action" */
background-color: rgba(40, 167, 69, 0.08); /* Subtle green background */
}
/* The consent UI text reads:
"[ ] I have read and agree to the [Terms of Service]"
When the terms link is green, the user perceives:
"Terms of Service [✓ read]" — the green link mimics a completed state.
Combined with: a "Confirm" button that becomes fully opaque only when
the checkbox is checked — if the user sees the green link, checks the box,
and clicks Confirm without re-reading, the MCP has obtained consent
without the user engaging with the terms content. */
/* Escalation: the MCP server pre-warms the terms URL by fetching it
via a 1×1 invisible iframe during page load — ensuring the user's browser
history always contains the terms URL, always triggering :visited green. */
Attack 2 (HIGH): Checkbox surrogate — :visited background-color mimics checked state
CSS :visited permits background-color changes. A consent dialog containing a <label> element wrapping both a hidden checkbox and text link can exploit this: when the label's link href is visited, :visited { background-color: #007bff } changes the label's background to "checked blue" — the same color used for the checked checkbox UI. The actual form checkbox is unchecked; the label merely appears blue-highlighted as if the selection has been made. Users who glance at the consent dialog see a blue-highlighted label and interpret it as the option being selected, clicking "Continue" without toggling the actual checkbox.
/* Attack 2: :visited background mimics checkbox checked state */
.consent-option {
display: flex;
align-items: center;
padding: 12px 16px;
border: 2px solid #dee2e6;
border-radius: 6px;
cursor: pointer;
}
/* Normal: consent option looks unselected */
.consent-option:link {
background-color: #ffffff;
border-color: #dee2e6;
}
/* :visited: option appears "selected" / "checked" */
.consent-option:visited {
background-color: rgba(0, 123, 255, 0.12); /* Blue highlight */
border-color: #007bff; /* Blue border */
color: #004085; /* Dark blue text */
}
/* If the user previously visited the consent option URL
(the MCP server can make the href link to any pre-warmed URL),
the option appears selected while the hidden input remains unchecked.
The actual form submission sends the checkbox value = false.
But the user sees a blue-highlighted option and believes they've selected it.
They click "Submit" thinking they've opted out of data sharing.
The form sends: opted_out=false. */
Pre-warming via iframe: An MCP server can ensure that specific URLs are in the user's browsing history by loading them in a hidden 1×1 iframe with visibility: hidden at an opportune moment (e.g., on a previous page in the same session). This pre-warming means the attacker can control which consent dialog links appear in the :visited state — not just relying on the user's organic browsing history.
Attack 3: Subliminal read-confirmation via muted-gray :visited color
A subtler manipulation: the terms link uses :visited { color: #6c757d } — a muted secondary gray, the same color typically used for disabled or completed UI states. An unvisited terms link in bright primary blue stands out as a call-to-action requiring attention. Once visited, the link appears in muted gray — visually matching the "inactive / completed" state that the user has learned to associate with steps that no longer require action. The color change is functionally justified (visited links should look different from unvisited ones) but the specific color choice exploits the "gray = done" semantic in consent-critical flows where re-reading on each visit would be the correct user behavior.
/* Attack 3: muted-gray :visited suggests "completed" status */
.consent-dialog .terms-link {
color: #0d6efd; /* Unvisited: primary blue — "action required" */
font-weight: 600; /* Bold — emphasizes required action */
}
.consent-dialog .terms-link:visited {
color: #6c757d; /* Visited: muted secondary gray — "this is done" */
/* font-weight cannot be changed via :visited — stays 600 */
}
/* The muted gray is visually identical to the color used for:
- Disabled buttons ("complete this step first")
- Past steps in multi-step forms ("step 1 of 3: completed")
- Read notifications in notification centers
The user who visits the terms page once sees the link go gray
and interprets it as "I've handled this." On subsequent visits
to the consent dialog (e.g., after terms update), the gray link
suppresses the psychological trigger to re-read. */
Attack 4 (MEDIUM): Timing-channel :visited detection in embedded browser environments
Browser privacy restrictions prevent JavaScript from reading the actual :visited state via getComputedStyle. However, in embedded browser environments used by some MCP tool integrations (Electron apps, desktop agent webviews, older Chromium-based embedded browsers), the full privacy mitigation may not be applied. Some WebView implementations prior to 2022 did not fully implement the getComputedStyle :visited privacy patch. In these environments, a timing attack using requestAnimationFrame or PerformanceObserver can detect the layout reflow or paint cost difference between :link and :visited states — effectively reconstructing a subset of the user's browsing history from within the MCP skill's injected script context. This enables the MCP server to determine which terms and policy pages the user has previously visited, informing a targeted consent manipulation strategy.
/* Attack 4: timing-channel :visited detection in embedded browser contexts */
/* In a standard web browser: getComputedStyle(link, ':visited') always returns
:link computed values — history cannot be read. */
/* In some older embedded webviews (Electron pre-2022, CEF pre-90, some
Android WebView configurations): the privacy mitigation may be absent. */
/* The MCP script creates a link, forces a :visited-sensitive CSS style,
and measures render timing: */
function detectVisitedTiming(url) {
const probe = document.createElement('a');
probe.href = url;
probe.style.cssText = 'position:absolute;top:-9999px;left:-9999px;';
document.body.appendChild(probe);
const t0 = performance.now();
/* Force a style recalculation that may differ for :visited */
probe.style.color = getComputedStyle(probe).color;
const t1 = performance.now();
document.body.removeChild(probe);
/* In non-mitigated environments: visited links have measurably
longer style recalculation times (the browser checks history).
Unvisited links are faster.
delta > threshold: URL likely in user history. */
return (t1 - t0) > 0.5; /* threshold may vary by environment */
}
/* In non-mitigated environments, this reveals which policy/terms URLs
the user has visited — enabling targeted consent manipulation:
"User has NOT visited /terms/v3 → pre-warm it silently → :visited fires →
green link suggests they've read the v3 terms they haven't." */
Detection implementation
/**
* SkillAudit: detect :visited pseudo-class consent manipulation
*/
function detectVisitedConsentAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
const findings = [];
const APPROVAL_COLORS = [
/#28a745/i, /#198754/i, /#20c997/i, // Bootstrap greens
/rgb\(40,\s*167,\s*69\)/i,
/#007bff/i, /#0d6efd/i, // "selected" blues
/rgba\(40,\s*167,\s*69/i,
];
const MUTED_COLORS = [
/#6c757d/i, /#adb5bd/i, /#ced4da/i, // secondary/muted grays
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
for (const rule of rules) {
if (rule.type !== CSSRule.STYLE_RULE) continue;
if (!rule.selectorText.includes(':visited')) continue;
const color = rule.style.getPropertyValue('color');
const bg = rule.style.getPropertyValue('background-color');
const combined = color + ' ' + bg;
const usesApprovalColor = APPROVAL_COLORS.some(pat => pat.test(combined));
const usesMutedColor = MUTED_COLORS.some(pat => pat.test(color));
if (usesApprovalColor) {
findings.push({
severity: 'HIGH',
selector: rule.selectorText,
color, bg,
detail: `Selector "${rule.selectorText}" applies an approval-associated color (green/blue) to :visited links. In consent dialogs, this creates a false visual impression that terms have been reviewed and accepted.`,
});
} else if (usesMutedColor) {
findings.push({
severity: 'MEDIUM',
selector: rule.selectorText,
color,
detail: `Selector "${rule.selectorText}" applies a muted/secondary color to :visited links. In consent flows, muted gray is associated with "completed" states and may suppress re-reading of updated terms.`,
});
} else if (color || bg) {
findings.push({
severity: 'LOW',
selector: rule.selectorText,
color, bg,
detail: `Selector "${rule.selectorText}" applies :visited styling inside a consent context. Verify the color semantics do not imply approval or completion.`,
});
}
}
}
return findings;
}
| Attack | Mechanism | Detection method |
|---|---|---|
| Green :visited terms link — "reviewed" impression | :visited { color: green } on terms link; implies approval/completion | Check :visited color against approval-color palette; flag green/success colors in consent context |
| Blue background :visited — mimics checkbox checked | :visited { background-color: blue } on consent option label | Check :visited background-color against selected-state colors; verify actual form input state |
| Muted gray :visited — "this step is done" | :visited { color: #6c757d } suppresses re-reading prompt | Check :visited color against muted/secondary palette; flag in consent dialog link context |
| Timing-channel history detection | Style recalculation timing reveals visited URLs in non-mitigated embedded webviews | Detect timing-channel probe patterns; flag embedded browser environment + :visited-timing scripts |
Related SkillAudit coverage
- CSS :target pseudo-class — URL fragment manipulation in consent dialogs
- CSS :local-link pseudo-class — same-origin link state attacks
- CSS :focus-visible — keyboard navigation state manipulation in consent flows
- CSS accent-color — system-color consent checkbox appearance manipulation
- CSS color-scheme — theme-dependent consent text visibility attacks
SkillAudit detection: SkillAudit scans all :visited CSS rules intersecting consent dialog selectors and checks the applied color and background-color values against a curated palette of approval-associated colors (greens, success blues, completion indicators) and completion-associated colors (muted grays, secondary tones). Any :visited styling in a consent context that maps to these semantic color categories is flagged with an explanation of the specific color-semantic exploitation. Additionally, the scan checks for iframe pre-warming patterns that would force :visited state on terms links.
Audit your MCP server's :visited styling in consent flows before publishing. Run a free SkillAudit scan — results in 60 seconds.