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

High orientation:portrait hides consent button via display:none or opacity:0 — covers all phones in default vertical hold and portrait-mode tablets; desktop and landscape-only audit tools see no problem; detected by CSSOM scan for portrait-orientation hide rules on consent elements and computed-style check on portrait devices.
High landscape + virtual keyboard collapses visualViewport below consent's BCR.bottom — consent technically display:block and opacity:1 but physically below the keyboard; detected by comparing getBoundingClientRect().bottom to visualViewport.height in landscape, and by CSSOM scan for negative bottom offsets in landscape rules.
Medium orientationchange JS swap — interactive consent button replaced with non-interactive element (div with no listeners, or disabled button) at device rotation moment; detected by source scan for orientationchange or screen.orientation.change listeners combined with DOM manipulation or pointer-events injection near consent elements.
Medium orientation-gated scroll anchor displaces page — scrollIntoView() or scroll-snap landscape rules scroll the page away from the consent section on rotation; consent remains visible in DOM but is off-screen; detected by BCR check outside viewport and CSSOM scan for orientation-gated scroll-snap rules.

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.