Security Guide
MCP server CSS orientation media query security — consent hidden in portrait or landscape only, landscape + virtual keyboard collapses viewport, orientationchange JS swap, orientation-gated scroll anchor
CSS @media (orientation) reports whether the viewport width is greater than its height (landscape) or not (portrait). MCP servers exploit this to hide consent only in one rotation state — auditors testing in the other state miss the bypass entirely. Landscape with a soft keyboard open shrinks viewport height by up to 60%, scrolling fixed-bottom consent off-screen without any visibility change.
CSS orientation media feature — overview
@media (orientation: portrait) matches when height >= width. @media (orientation: landscape) matches when width > height. Most phones default to portrait; most desktops are always landscape. An orientation-gated consent hide is a rotation-state-specific bypass: whichever rotation the auditor does not test is the attack surface. Related media queries: aspect-ratio, hover, display-mode.
Attack 1: orientation: portrait hides consent — covers all default phone sessions
Hiding the consent button under @media (orientation: portrait) removes it from all phones held vertically — the default state for the majority of mobile sessions. Automated auditors that simulate a portrait-oriented mobile viewport and pass at the window-open state never see the consent button. A desktop or landscape-only auditor sees the button correctly and reports a pass.
/* Attack: consent hidden in portrait — all default-hold phone sessions */
.consent-btn {
display: block; /* visible in landscape */
}
@media (orientation: portrait) {
.consent-btn {
display: none;
/* Affects: all phones in default hold (60%+ of mobile traffic),
tablets in portrait orientation.
A landscape-only desktop audit sees display:block and passes.
A portrait mobile test sees display:none. */
}
}
// Detection: CSSOM scan for orientation:portrait hide on consent elements
function auditOrientationPortraitHide(consentEl) {
const isPortrait = window.matchMedia('(orientation: portrait)').matches;
const sheets = Array.from(document.styleSheets);
for (const sheet of sheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.MEDIA_RULE) continue;
const mq = rule.conditionText || rule.media.mediaText;
if (!/orientation\s*:\s*portrait/.test(mq)) continue;
for (const inner of rule.cssRules) {
if (inner.type !== CSSRule.STYLE_RULE) continue;
if (!consentEl.matches(inner.selectorText)) continue;
const d = inner.style.display;
const o = inner.style.opacity;
const v = inner.style.visibility;
if (d === 'none' || o === '0' || v === 'hidden') {
console.warn('[SkillAudit] orientation:portrait hides consent element;',
'affects all phones in default-hold vertical state;',
'selector:', inner.selectorText);
}
}
}
} catch (e) { /* cross-origin */ }
}
// Also check computed on current device if portrait
if (isPortrait) {
const cs = getComputedStyle(consentEl);
if (cs.display === 'none' || cs.opacity === '0' || cs.visibility === 'hidden') {
console.warn('[SkillAudit] consent element hidden on current portrait device:', consentEl);
}
}
}
Default-state targeting: Portrait is the default orientation for most phone users. A consent hide that triggers in portrait affects the majority of mobile sessions without requiring any unusual device state. Desktop audit tools default to landscape viewports and will not catch this.
Attack 2: landscape + virtual keyboard collapses viewport, consent scrolls off-screen
When a user opens a soft keyboard in landscape orientation, the visible viewport height can shrink by 40–60% — on a phone with a 360px landscape viewport height, the keyboard may consume 200px, leaving 160px of usable space. A consent banner positioned at the bottom using position: fixed; bottom: 0 remains technically on-screen at zero-keyboard state but slides under the keyboard when it opens. The consent button stays display: block and opacity: 1 — all computed-style checks pass — but the button is not in the visible viewport area because visualViewport.height is smaller than window.innerHeight.
/* Attack: fixed-bottom consent with landscape-specific bottom offset */
.consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
@media (orientation: landscape) {
.consent-banner {
bottom: -60px; /* Pushed below fold in landscape */
/* When soft keyboard adds another 150px of scroll offset,
combined with the -60px, the banner is 210px below the
visible viewport. All style checks pass: display:block,
opacity:1, visibility:visible. BCR.bottom > visualViewport.height. */
}
}
// Detection: check BCR against visualViewport in landscape
function auditLandscapeViewportCollapse(consentEl) {
const isLandscape = window.matchMedia('(orientation: landscape)').matches;
if (!isLandscape) return;
const bcr = consentEl.getBoundingClientRect();
const vvh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
if (bcr.bottom > vvh || bcr.top > vvh) {
console.warn('[SkillAudit] consent element is below visualViewport in landscape;',
'bcr.bottom:', bcr.bottom, 'visualViewport.height:', vvh,
'| element may be hidden under keyboard or pushed off-screen by landscape-specific CSS;',
'element:', consentEl);
}
// Also check landscape-specific bottom offsets in CSSOM
const sheets = Array.from(document.styleSheets);
for (const sheet of sheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.MEDIA_RULE) continue;
const mq = rule.conditionText || rule.media.mediaText;
if (!/orientation\s*:\s*landscape/.test(mq)) continue;
for (const inner of rule.cssRules) {
if (!consentEl.matches(inner.selectorText)) continue;
const bottom = inner.style.bottom;
if (bottom && bottom.startsWith('-')) {
console.warn('[SkillAudit] landscape orientation sets negative bottom offset on consent element;',
'bottom:', bottom, 'selector:', inner.selectorText);
}
}
}
} catch (e) {}
}
}
Attack 3: orientationchange JS event listener swaps consent button
A script listens for the orientationchange event (or resize events that indicate rotation) and replaces the interactive consent button with a non-interactive element at the moment of rotation. When the user rotates their device, the swap fires before any interaction with the consent button. The replacement element looks identical but has no event listeners. The attack is not visible in a static CSSOM audit and requires JavaScript source analysis.
// Attack: orientationchange listener swaps consent button
window.addEventListener('orientationchange', () => {
const btn = document.querySelector('.consent-btn');
if (!btn) return;
// Determine new orientation after change settles
setTimeout(() => {
const isLandscape = window.innerWidth > window.innerHeight;
if (isLandscape) {
// Replace with non-interactive div
const fake = document.createElement('div');
fake.className = btn.className;
fake.textContent = btn.textContent;
fake.style.cssText = btn.style.cssText;
// div receives no click events, no keyboard events
btn.parentNode.replaceChild(fake, btn);
}
}, 200);
});
// Subtler variant: ScreenOrientation API
screen.orientation?.addEventListener('change', () => {
if (screen.orientation.type.startsWith('landscape')) {
document.querySelector('.consent-btn')?.setAttribute('disabled', '');
document.querySelector('.consent-btn')?.style.setProperty('pointer-events', 'none');
}
});
// Detection: JS source scan for orientationchange + DOM manipulation
function auditOrientationChangeJS() {
for (const script of document.querySelectorAll('script')) {
const src = script.textContent;
if (!src) continue;
const hasOrientationEvent = /orientationchange|orientation\.type|screen\.orientation/.test(src);
if (!hasOrientationEvent) continue;
const hasManipulation = [
/replaceChild|createElement|removeChild/,
/pointer-events.*none/,
/display.*none/,
/\.disabled\s*=|setAttribute.*disabled/,
/style\.(opacity|display|visibility|pointerEvents)\s*=/,
].some(p => p.test(src));
if (hasManipulation) {
console.warn('[SkillAudit] script uses orientationchange or screen.orientation event with DOM manipulation;',
'verify consent button remains interactive after device rotation;',
'script:', script.src || '(inline)');
}
}
// Also scan addEventListener calls
const allSrc = Array.from(document.querySelectorAll('script')).map(s => s.textContent).join('\n');
if (/addEventListener.*resize/.test(allSrc) && /orientation|rotate/.test(allSrc) && /consent/.test(allSrc)) {
console.warn('[SkillAudit] resize listener with orientation/rotate logic near consent keyword; audit for rotation-triggered consent swap');
}
}
Attack 4: orientation-gated scroll anchor displaces page to hide consent section
CSS scroll-snap or a script-driven scrollIntoView() triggered on orientation change can scroll the page to a named anchor far from the consent section. The consent element remains display: block and opacity: 1, but it is no longer in the visible viewport — the page has scrolled past it. This requires a combination of orientation detection and scroll manipulation rather than direct visibility changes, making it invisible to typical computed-style audits.
// Attack: orientation change triggers scroll away from consent
window.addEventListener('orientationchange', () => {
setTimeout(() => {
// Scroll to top of page or to a marketing section,
// pushing consent section below the fold
const hero = document.querySelector('#hero');
hero?.scrollIntoView({ behavior: 'smooth' });
// consent-section is further down the page —
// user must manually scroll back to find it
}, 300);
});
// CSS variant: scroll-snap landscape locks viewport to hero snap point
/* @media (orientation: landscape) {
html { scroll-snap-type: y mandatory; }
#hero { scroll-snap-align: start; height: 100vh; }
.consent-section {
scroll-snap-align: start;
height: 100vh;
/* Snap points force user to swipe through full screens —
consent section is a whole-screen swipe away from hero,
and snap momentum may skip it in fast-swipe gestures */
}
} */
// Detection: BCR + scroll position check after orientation simulation
async function auditOrientationScrollDisplacement(consentEl) {
// Check if element is currently in view
const bcr = consentEl.getBoundingClientRect();
const vph = window.innerHeight;
const vpw = window.innerWidth;
if (bcr.top > vph || bcr.bottom < 0 || bcr.left > vpw || bcr.right < 0) {
console.warn('[SkillAudit] consent element is outside current viewport via scroll;',
'bcr:', JSON.stringify(bcr), '| check scroll-snap landscape rules and orientationchange scroll listeners');
}
// Check scroll-snap landscape rules
const sheets = Array.from(document.styleSheets);
for (const sheet of sheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.MEDIA_RULE) continue;
const mq = rule.conditionText || rule.media.mediaText;
if (!/orientation/.test(mq)) continue;
for (const inner of rule.cssRules) {
if (/scroll-snap-type|scroll-snap-align/.test(inner.style?.cssText || '')) {
console.warn('[SkillAudit] orientation-gated scroll-snap rule detected;',
'verify consent section is reachable via scroll snap in both orientations;',
'media:', mq, 'selector:', inner.selectorText);
}
}
}
} catch (e) {}
}
}
Findings summary
SkillAudit audits CSS orientation media query rules on consent elements, checks BCR against visualViewport dimensions in both orientations, and scans JavaScript for orientationchange listeners combined with DOM manipulation. Run a free audit on your MCP server.