Security Guide
MCP server scheduler.postTask() security — task priority starvation, AbortSignal race conditions, precision timer bypass, task queue depth surveillance
The Prioritized Task Scheduling API (scheduler.postTask()) gives JavaScript explicit control over task execution priority, with three named levels: 'user-blocking' (highest, runs before rendering), 'user-visible' (default, balanced with rendering), and 'background' (lowest, yields to everything). In MCP contexts where tool code runs in the browser's main thread, access to scheduler.postTask() creates four distinct attack surfaces: flooding the task queue with 'user-blocking' priority synthetic tasks starves user-visible rendering and freezes the UI; AbortSignal cancellation mid-task leaves shared state in an inconsistent torn state if the task handler doesn't correctly handle AbortError; the delay parameter fires tasks with precision sufficient to replace setTimeout() in timing attacks after performance.now() coarsening; and PerformanceObserver long-task entries encoding task queue depth reveal background activity levels as a covert surveillance channel.
user-blocking priority flooding — rendering starvation
The 'user-blocking' priority level is designed for work that must complete before the browser renders the next frame — it is the highest priority in the scheduler. The browser services 'user-blocking' tasks before performing style recalculation, layout, paint, or compositing. A malicious MCP tool can flood the scheduler with high-priority synthetic tasks, continuously preempting rendering work and freezing visible page updates for the duration of the flood.
// user-blocking priority flood — rendering starvation DoS
// The browser will NOT render while there are pending user-blocking tasks
function startRenderingStarvation() {
let running = true;
async function floodTask() {
while (running) {
// Schedule synthetic computation at highest priority
await scheduler.postTask(() => {
// Burn ~5ms of CPU per task — enough to delay one frame
const t = performance.now();
while (performance.now() - t < 5) { /* busy wait */ }
}, { priority: 'user-blocking' });
// Immediately reschedule — maintains continuous high-priority queue pressure
}
}
// Multiple concurrent flood chains compound the effect
floodTask();
floodTask();
floodTask();
// Result: rendering is completely blocked
// User sees a frozen page — buttons don't respond, animations stop
// Console still works (separate process in Chrome), DevTools accessible
// The tab appears completely hung from the user's perspective
}
// Defense: MCP client sandboxes should rate-limit scheduler.postTask() calls
// and enforce priority caps — denying 'user-blocking' to tool code
// TaskController allows dynamic priority adjustment — downgrade tool tasks to 'background'
const controller = new TaskController({ priority: 'background' });
// All tool tasks use this controller — cannot be upgraded to user-blocking
await scheduler.postTask(toolWork, { signal: controller.signal });
This attack requires no permissions. scheduler.postTask() is available to all JavaScript in the browser without any capability gating. An MCP tool that executes in the main thread — including any tool that returns JavaScript code that the MCP client evaluates — has full access to the scheduler at any priority level. The only mitigations are sandboxing (iframe with no shared event loop) or main-thread isolation via Worker.
AbortSignal abort() — torn shared state on task cancellation
Tasks posted via scheduler.postTask() can be cancelled by calling abort() on the TaskController signal passed in the options. When a task is cancelled mid-execution, the task's promise rejects with an AbortError. If the task handler modifies shared state (closures, module-level variables, IndexedDB, Cache) and does not handle the AbortError cleanly, the shared state may be left in a partially modified — torn — condition. A malicious actor who controls when abort() is called can race against a specific state transition to produce a predictable inconsistency.
// AbortSignal race condition — torn shared state via strategically timed abort()
// Shared state: user authentication record in module-level variable
let authState = { userId: null, token: null, sessionValid: false };
async function updateAuthRecord(newUserId, newToken) {
// Dangerous: multi-step state update without abort handling
await scheduler.postTask(async (options) => {
authState.userId = newUserId; // ← abort here → userId set, token not set
await fetchTokenValidation(newToken); // async operation
authState.token = newToken; // ← if aborted during fetch, token never set
authState.sessionValid = true; // ← this never runs if aborted after fetch
}, { signal: controller.signal });
}
// Attacker calls controller.abort() immediately after authState.userId is written
// Result: authState = { userId: 'victim', token: null, sessionValid: false }
// Torn state: userId is set to the victim but session is invalid
// Subsequent code that only checks userId (and not sessionValid) may grant access
// Safe pattern: treat the entire update as an atomic operation
async function safeUpdateAuthRecord(newUserId, newToken) {
await scheduler.postTask(async () => {
// Validate everything first, then apply atomically
const validated = await fetchTokenValidation(newToken);
if (!validated) throw new Error('invalid token');
// Apply only when all validation is complete
Object.assign(authState, { userId: newUserId, token: newToken, sessionValid: true });
}, { signal: controller.signal });
// If aborted, no partial write happened — authState unchanged
}
delay parameter as high-precision timer after performance.now() coarsening
Browsers coarsen performance.now() to approximately 100µs resolution (1µs in cross-origin isolated contexts) as a Spectre mitigation. setTimeout() and setInterval() are subject to minimum delay clamping (4ms in background tabs, 1ms in foreground). The scheduler.postTask() delay parameter is a newer API that fires tasks after the specified delay in milliseconds. In practice, the delay timer is implemented with higher precision than setTimeout() and is not subject to the same minimum-delay clamping in all browser versions, providing an alternative timing source for attacks that need better-than-4ms precision.
// scheduler.postTask() delay as a high-precision timer oracle
// Used to measure intervals with better resolution than setTimeout()
async function measureIntervalWithScheduler(n = 50) {
const measurements = [];
for (let i = 0; i < n; i++) {
const start = performance.now();
await scheduler.postTask(() => {}, {
priority: 'user-blocking', // execute immediately, no queuing delay
delay: 0 // minimum delay — measures scheduler overhead
});
measurements.push(performance.now() - start);
}
// The variance in measurements encodes system load:
// Low variance (< 0.5ms) → system idle
// High variance (> 5ms) → background tasks running (e.g., AV scan, compilation)
// This is a covert channel for background activity level inference
return measurements;
}
// More targeted: measure time between two specific operations
// by scheduling a task to fire after N delay units and comparing actual vs requested
// The difference is a high-precision measure of system scheduling jitter
// — useful for timing attacks that need more resolution than coarsened performance.now()
PerformanceObserver long-task entries — task queue depth as covert channel
The Long Tasks API (part of PerformanceObserver) surfaces longtask entries for any task that blocks the main thread for more than 50ms. An MCP tool can observe these entries to measure how busy the browser's task scheduler is — how many background tasks are competing for the main thread, whether a background sync is running, whether a service worker is active, whether a content script is executing. This background activity level is a covert channel: it encodes information about what the user is doing in other tabs, what browser extensions are active, and what system-level operations are occurring.
// PerformanceObserver longtask entries — task queue depth surveillance
const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'longtask') {
console.log({
duration: entry.duration, // how long the task blocked the thread
startTime: entry.startTime, // when it started
attribution: entry.attribution // what caused it: script, layout, style, etc.
});
// High-duration longtask entries reveal:
// - Background extension content scripts executing (attribution: 'script')
// - Service worker sync handlers running (attribution: 'script')
// - GC pauses (typically 50-200ms, periodic)
// - CSS/layout thrash from other page sections
// - IndexedDB operations on other pages (same-origin)
}
}
});
observer.observe({ type: 'longtask', buffered: true });
// Combined with scheduler.postTask() delay measurements:
// 1. Post a 'background' task with delay:0 → should fire ~immediately when idle
// 2. Measure actual fire time − scheduled time = queue depth proxy
// 3. If delay > 100ms → heavy background activity present
// 4. Correlate with longtask attribution to identify what is running
// Result: activity fingerprint of the browser and OS without any permission
| Risk | Mechanism | Defense |
|---|---|---|
| Rendering starvation | user-blocking priority flood preempts all rendering work | Sandbox tool code; cap task priority at user-visible; rate-limit postTask calls |
| Torn shared state | abort() during async task leaves state partially written | Apply state atomically after validation; treat AbortError as rollback signal |
| Precision timer bypass | postTask delay fires with more precision than setTimeout after clamping | Restrict scheduler.postTask() access in tool sandboxes; use separate Worker thread |
| Task queue depth surveillance | longtask observer entries encode background activity level | Restrict PerformanceObserver types in sandboxed iframes; Worker isolation |
SkillAudit findings for scheduler.postTask() misuse
await expressions, and the task is linked to an AbortSignal. Mid-execution abort can leave state inconsistently updated. Grade impact: −14.
Audit your MCP server for scheduler misuse
SkillAudit checks for task priority flooding, AbortSignal race patterns, delay-based timing attacks, and longtask surveillance. Paste a GitHub URL and get a graded report in 60 seconds.
Run a free audit →