Security Guide
MCP server CSS nav-up / nav-right / nav-down / nav-left security — spatial navigation focus hijack that bypasses consent
CSS spatial navigation properties (nav-up, nav-right, nav-down, nav-left) specify which focusable element receives focus when the user presses a directional key. Defined in CSS Basic User Interface Module Level 3, they are primarily used in TV browsers, game console UIs, and automotive displays where D-pad or arrow-key navigation replaces mouse. An MCP server overrides these properties to create a focus route that cycles around the consent dialog, making the Approve button unreachable via directional navigation.
How CSS spatial navigation properties work
Spatial navigation allows users to move focus between interactive elements using directional keys (Up/Down/Left/Right arrow keys, or D-pad input on game controllers and smart TV remotes). When nav-up, nav-right, nav-down, or nav-left is set on a focusable element, that property explicitly specifies the next element to receive focus in that direction — overriding the browser's default geometric heuristic.
/* Syntax */
.element {
nav-up: #element-id | auto | inherit;
nav-right: #element-id | auto | inherit;
nav-down: #element-id | auto | inherit;
nav-left: #element-id | auto | inherit;
}
/* Example: explicit focus routing */
#button-a { nav-right: #button-b; nav-down: #button-c; }
#button-b { nav-left: #button-a; nav-down: #button-d; }
/* User navigating right from A goes to B; from B, left returns to A. */
/* Without these properties: browser uses geometric proximity heuristic */
/* With them: author-defined focus graph overrides spatial geometry */
Attack 1 (CRITICAL): approve button bypass — directional navigation never reaches consent
The consent dialog contains an Approve button and a Deny button arranged vertically. Above the consent dialog is a menu element. The MCP server sets nav-down on the menu element to point to the Deny button (skipping the consent dialog entirely) and sets nav-up on the Deny button to point back to the menu element. Pressing Down from the menu goes directly to Deny; there is no directional path to Approve.
/* Attack 1: Approve button unreachable via D-pad navigation */
/* Assumed layout (top to bottom): #menu → #consent-approve → #consent-deny */
/* Normal spatial nav would route: #menu → Down → #consent-approve → Down → #consent-deny */
/* MCP override: skip consent-approve entirely */
#menu {
nav-down: #consent-deny !important;
/* Pressing Down from menu goes to Deny, bypassing Approve */
}
#consent-deny {
nav-up: #menu !important;
/* Pressing Up from Deny returns to menu, bypassing Approve */
}
/* RESULT:
The user navigates: menu → Down → Deny.
From Deny, pressing Down goes to whatever element is below the consent dialog.
#consent-approve is never in the spatial navigation path.
The user can only press Deny or navigate past the dialog without approving.
IMPORTANT: #consent-approve remains in DOM, fully visible, with correct text.
It is still reachable via Tab (sequential focus) but not via directional arrow keys.
On TV browsers and game UIs where Tab is unavailable, Approve is permanently
inaccessible via the only available navigation mechanism.
SCANNER GAP:
Static scanners check that the Approve button has correct text, is visible,
and is in the DOM. It passes all these checks.
The attack is in the nav-* routing on OTHER elements — not on the Approve button itself. */
TV and game console impact: On smart TV browsers (LG webOS, Samsung Tizen), game consoles (PlayStation, Xbox), and automotive UIs, D-pad navigation is the only available input method. A consent dialog whose Approve button is excluded from the directional focus graph cannot be approved — only dismissed or bypassed.
Attack 2 (CRITICAL): focus loop trap — directional cycle that excludes the consent dialog
The MCP server creates a closed focus cycle among non-consent elements (header, sidebar, footer) using nav-* properties. Each element's nav-* routes to another element in the cycle, and no element in the cycle has a nav-* path into the consent dialog. The user is trapped in the cycle.
/* Attack 2: closed focus loop excluding consent dialog */
/* Elements in the trap: #header, #sidebar, #footer-nav */
/* Consent dialog: #consent-overlay (not in the loop) */
#header {
nav-right: #sidebar;
nav-down: #footer-nav;
nav-left: #footer-nav;
nav-up: #footer-nav;
}
#sidebar {
nav-left: #header;
nav-down: #footer-nav;
nav-right: #header;
nav-up: #footer-nav;
}
#footer-nav {
nav-up: #header;
nav-right: #header;
nav-left: #sidebar;
nav-down: #header;
}
/* RESULT:
All directional paths among #header, #sidebar, #footer-nav form a closed loop.
None of the nav-* properties point to #consent-overlay or its children.
The browser's geometric heuristic is overridden by the explicit routing.
#consent-overlay is in the DOM, visible on screen, but has no inbound nav-* route.
The user cannot navigate into the consent dialog using directional keys.
Tab navigation still works on desktop, but D-pad-only environments have no access.
SCANNER GAP:
The consent dialog itself has no malicious CSS. The attack is distributed across
nav-* properties on unrelated page elements forming the trap loop.
A scanner focused on the consent dialog's own CSS properties finds nothing. */
Attack 3: deny-only routing — nav-* directs all paths to the Deny button
Rather than excluding the consent dialog from navigation entirely, the MCP server routes all directional paths to the Deny button. Approve is reachable only from Deny via Left/Right, but the initial nav-* path always lands on Deny first — exploiting user inertia and time pressure to elicit a Deny without seeing the Approve option clearly.
/* Attack 3: deny-first routing — all inbound paths land on Deny */
/* Layout: Approve on left, Deny on right of consent button row */
/* Elements above consent: #step-indicator (progress bar) */
#step-indicator {
nav-down: #consent-deny !important;
/* First focus entering consent dialog lands on Deny, not Approve */
}
#consent-approve {
nav-right: #consent-deny;
nav-up: #step-indicator;
/* Approve's right leads to Deny; Approve's up leads back out of dialog */
}
#consent-deny {
nav-left: #consent-approve;
nav-up: #step-indicator;
/* Deny's left leads to Approve; Deny's up leads back out of dialog */
/* Natural navigation: Down from step-indicator → Deny → Left → Approve */
}
/* RESULT:
User pressing Down from the progress bar lands on Deny (the "wrong" button).
Pressing Enter at this point activates Deny.
To reach Approve requires an additional Left keypress — a non-obvious step.
Combined with time pressure or distraction, users activate Deny without reviewing Approve.
SCANNER GAP:
Both buttons are visible and in DOM. The attack is in the *order* of focus landing,
not in hiding any element. Behavioral analysis of directional navigation paths is
required, not just CSS property inspection. */
Attack 4: spatial navigation pointing off-screen
The MCP server sets nav-* properties to point to elements that are off-screen or have display: none. In some browser implementations, focus on an invisible element produces no visible focus indicator and no keyboard event handler fires, silently advancing navigation to the next element via fallback — which may skip the consent dialog.
/* Attack 4: nav-* pointing to invisible element causes silent focus advance */
/* MCP injects an off-screen anchor element */
.nav-sink {
position: absolute;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
<div class="nav-sink" tabindex="0" id="focus-sink"></div>
/* Route focus into the sink before reaching consent */
#header {
nav-down: #focus-sink !important;
}
#focus-sink {
nav-down: #footer !important; /* skips consent dialog on second Down press */
}
/* RESULT:
First Down from header → #focus-sink (invisible, no visual focus indicator).
User presses Down again → #footer (skips consent entirely).
Many TV browsers treat invisible-element focus as a silent pass-through,
effectively consuming one directional keypress without visible effect.
From the user's perspective: Down → nothing visible → Down → footer.
The consent dialog was between header and footer but is completely bypassed.
SCANNER GAP: The off-screen sink element may not be in the consent element tree at all.
nav-* properties on surrounding elements are only caught by a full focus graph audit. */
Detection strategy: Build the full directed focus graph implied by all nav-* properties in the document. Verify that every focusable element in the consent dialog has at least one inbound edge from elements that are reachable from page entry points. Flag any consent-critical element (Approve button, consent checkbox) with no inbound nav-* path as a HIGH/CRITICAL finding.
Scanner gap summary
| Attack | Severity | Why scanners miss it |
|---|---|---|
| Approve button excluded from directional focus path | CRITICAL | Attack is on nav-* of surrounding elements, not on Approve button itself |
| Closed focus loop excluding consent dialog | CRITICAL | Requires graph analysis of all nav-* in document; consent element CSS is clean |
| Deny-first routing — all paths land on Deny | HIGH | Both buttons visible; attack is behavioral (navigation order), not CSS property value |
| Off-screen nav sink — silent focus advance past consent | HIGH | Sink element is off-screen but focusable; sink not in consent element tree |
Focus graph audit implementation
// Detection: build directional focus graph and check for inbound paths to consent
function auditSpatialNavPaths(consentElements) {
const graph = new Map(); // element → { up, right, down, left }
const allFocusable = document.querySelectorAll('[tabindex], button, a, input, select, textarea');
allFocusable.forEach(el => {
const cs = getComputedStyle(el);
graph.set(el, {
up: cs.navUp !== 'auto' ? document.querySelector(cs.navUp) : null,
right: cs.navRight !== 'auto' ? document.querySelector(cs.navRight) : null,
down: cs.navDown !== 'auto' ? document.querySelector(cs.navDown) : null,
left: cs.navLeft !== 'auto' ? document.querySelector(cs.navLeft) : null,
});
});
// BFS from all page-entry focusable elements; check if consent elements are reachable
const reachable = new Set();
const queue = [document.querySelector('[autofocus]') || allFocusable[0]];
while (queue.length) {
const node = queue.shift();
if (!node || reachable.has(node)) continue;
reachable.add(node);
const edges = graph.get(node) || {};
Object.values(edges).forEach(target => { if (target) queue.push(target); });
}
return consentElements.map(el => ({
element: el,
reachableViaNavKeys: reachable.has(el),
risk: reachable.has(el) ? 'none' : 'CRITICAL — not reachable via directional navigation'
}));
}
Related SkillAudit coverage
- CSS focus-visible security — focus indicator manipulation hiding active focus
- CSS pointer-events security — click interception attacks on consent elements
- CSS touch-action security — touch event manipulation on consent UI
- CSS user-select security — text selection manipulation in consent dialogs
SkillAudit detection: SkillAudit builds a complete spatial navigation focus graph from all nav-* properties, verifies reachability of all consent-critical interactive elements via directional traversal, and flags any Approve/consent button with no inbound directional path as a CRITICAL finding. The check runs on both desktop and simulated TV-browser contexts.
Audit your MCP server's CSS spatial navigation properties before publishing. Run a free SkillAudit scan — results in 60 seconds.