Security Model
OpenWorkers executes JavaScript in V8 isolates with resource isolation. This document details the sandboxing architecture.
Note: OpenWorkers is designed for running your own code on your own infrastructure – not for multi-tenant SaaS with untrusted third-party code. Cloudflare has dedicated security teams, 24h V8 patches, and years of hardening. If you need that level of security, use Cloudflare Workers. If you need to run untrusted code, add an extra layer (container, VM) around the runtime.
Quick Summary
| Layer | Protection | Default |
|---|---|---|
| Memory | V8 heap limits + custom ArrayBuffer allocator | 128 MB |
| CPU Time | POSIX timer (Linux) | 100 ms |
| Wall Clock | Watchdog thread | 60 seconds |
| Network | Fetch via operations handler | Controlled |
| Filesystem | Not exposed | Blocked |
| Processes | Not exposed | Blocked |
Defense in Depth
┌─────────────────────────────────────────────────────────────────┐
│ JavaScript Code │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ V8 Isolate Sandbox │
│ │
│ • Separate heap per worker │
│ • No shared memory between isolates │
│ • Limited API surface (no fs, no child_process) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Resource Limits │
│ │
│ • Memory: V8 heap + ArrayBuffer allocator │
│ • CPU: POSIX timer (Linux) / Wall-clock fallback │
│ • Concurrency: Semaphore-controlled worker pool │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Operations Handler │
│ │
│ • All I/O routed through Rust │
│ • Fetch requests validated/controlled │
│ • Bindings resolve credentials server-side │
└─────────────────────────────────────────────────────────────────┘ Memory Isolation
V8 Heap Limits
Each worker gets an isolated V8 heap with configurable limits:
pub struct RuntimeLimits {
pub heap_initial_mb: usize, // Default: 1 MB
pub heap_max_mb: usize, // Default: 128 MB
} When the heap limit is exceeded, V8 throws an out-of-memory error and the worker is terminated.
Custom ArrayBuffer Allocator
Problem: V8’s heap limits don’t cover ArrayBuffer allocations (Uint8Array, Buffer, etc.). A worker could allocate massive buffers without triggering heap limits.
Solution: Custom allocator that tracks and limits external memory:
Worker tries to allocate 1GB ArrayBuffer
│
▼
┌─────────────────────────────────────┐
│ Custom ArrayBuffer Allocator │
│ │
│ current: 50 MB │
│ max: 128 MB │
│ requested: 1024 MB │
│ │
│ 50 + 1024 > 128 → REJECT │
└─────────────────────────────────────┘
│
▼
RangeError: Array buffer allocation failed
│
▼
Worker terminated with MemoryLimit Implementation details:
- Uses
AtomicUsizefor lock-free tracking - Sequential consistency (
SeqCst) for strong ordering - Rollback on rejection (prevents quota exhaustion)
- Memory freed when buffers are garbage collected
CPU Time Limits
Linux: POSIX Timer
On Linux, we use CLOCK_THREAD_CPUTIME_ID to track actual CPU time:
Worker starts executing
│
▼
POSIX timer armed (100ms CPU time)
│
├──► JavaScript runs (uses CPU)
│
├──► await fetch(...) (CPU timer PAUSED - I/O wait)
│
├──► JavaScript runs (CPU timer RESUMES)
│
▼
Timer fires SIGALRM after 100ms of CPU time
│
▼
isolate.terminate_execution()
│
▼
Worker terminated with CpuTimeLimit Key properties:
- Counts ONLY actual CPU cycles
- I/O waits, network latency, sleep don’t count
- Per-thread timer (accurate for concurrent workers)
- One-shot timer per request
macOS/BSD: Wall-Clock Fallback
macOS doesn’t support per-thread CPU timers. Workers rely on wall-clock timeout instead.
Wall-Clock Timeout
Protects against slow operations (network waits, infinite loops with sleep):
// Watchdog thread spawned per execution
thread::spawn(move || {
match cancel_rx.recv_timeout(Duration::from_secs(30)) {
Ok(()) => { /* completed normally */ }
Err(Timeout) => {
isolate_handle.terminate_execution();
}
}
}); Default: 30 seconds
Cancellation: When worker completes, signal sent to watchdog thread for cleanup.
Termination Reasons
When a worker is terminated, we detect why (in priority order):
pub enum TerminationReason {
CpuTimeLimit, // CPU budget exhausted (Linux)
WallClockTimeout, // Wall-clock timeout exceeded
MemoryLimit, // Heap or ArrayBuffer limit hit
Exception(String), // JavaScript threw an error
Terminated, // External signal
Aborted, // abort() called
} Each reason maps to an HTTP status:
| Reason | HTTP Status |
|---|---|
| CpuTimeLimit | 429 Too Many Requests |
| MemoryLimit | 429 Too Many Requests |
| WallClockTimeout | 504 Gateway Timeout |
| Exception | 500 Internal Server Error |
Available APIs
Workers have access to a limited set of Web APIs:
| Category | APIs |
|---|---|
| Console | console.log, console.warn, console.error, console.debug |
| Fetch | fetch() (via operations handler) |
| Timers | setTimeout, setInterval, clearTimeout, clearInterval |
| Crypto | crypto.getRandomValues(), crypto.randomUUID(), crypto.subtle.* |
| Text | TextEncoder, TextDecoder, btoa(), atob() |
| Web APIs | Blob, File, FormData, Headers, Request, Response |
| Streams | ReadableStream, WritableStream, TransformStream |
| URL | URL, URLSearchParams |
| Performance | performance.now() (100µs precision) |
Crypto Algorithms
- Hash: SHA-1, SHA-256, SHA-384, SHA-512
- HMAC: All SHA variants
- Signatures: ECDSA (P-256, P-384), RSA (RS256, RS384, RS512)
Blocked APIs
The following are intentionally NOT available:
| Feature | Why Blocked |
|---|---|
| File System | No fs, no disk access |
| Child Processes | No spawn(), exec() |
| Raw Sockets | No TCP/UDP, only HTTP via fetch |
| Module System | No require(), no dynamic imports |
| Environment | No process.env (use env binding instead) |
| System Calls | Rust boundary prevents libc access |
| SharedArrayBuffer | Removed - can be used for Spectre timing attacks |
| Atomics | Removed - only useful with SharedArrayBuffer |
Network Control
All network access goes through the operations handler:
fetch("https://api.example.com")
│
▼
__nativeFetchStreaming() - Native V8 function
│
▼
SchedulerMessage::Fetch sent to event loop
│
▼
┌─────────────────────────────────────┐
│ Operations Handler │
│ │
│ • Validate URL (block localhost?) │
│ • Enforce rate limits │
│ • Add timeout per request │
│ • Track bytes in/out │
└─────────────────────────────────────┘
│
▼
reqwest::Client executes request Configurable controls:
- DNS blocklist (internal IPs, localhost)
- Rate limiting per worker
- Request/response size limits
- Per-request timeout
Environment Injection
Environment variables and bindings are injected as a frozen object:
// Injected by runtime
Object.defineProperty(globalThis, 'env', {
value: Object.freeze({
API_KEY: "sk-xxx", // From environment_values
KV: { get, put, ... }, // Binding object
STORAGE: { get, put, ... }
}),
writable: false,
enumerable: true,
configurable: false
}); Properties:
- Cannot be modified by worker code
- Cannot be deleted
- Credentials resolved server-side (worker never sees S3 keys, etc.)
Concurrency Limits
Sequential Worker Pool
Each worker runs in a V8 isolate, but V8 isolates cannot safely share a thread. V8’s RAII scopes require strict LIFO ordering that async task interleaving would break.
Solution: A sequential worker pool where each thread processes ONE worker at a time:
┌─────────────────────────────────────────────────────────────┐
│ Thread 0 Thread 1 ... Thread N-1 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Channel │ │ Channel │ │ Channel │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │LocalSet │ │LocalSet │ │LocalSet │ │
│ │ + RT │ │ + RT │ │ + RT │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │ │ │ │
│ [Task A] [Task D] [Task G] │
│ [Task B] queue [Task E] queue [Task H] │
│ [Task C] [Task F] [Task I] │
└─────────────────────────────────────────────────────────────┘
Round-robin distribution: A→0, B→1, C→2, D→0, E→1, ...
Sequential execution: Each thread runs ONE task at a time Configuration:
WORKER_POOL_SIZE = num_cpus // Thread count (default: CPU cores)
MAX_QUEUED_WORKERS = pool_size * 10 // Queue depth limit
// Example on 8-core machine:
// - 8 concurrent workers (one per thread)
// - Up to 80 queued workers waiting Why sequential?
V8’s HandleScope and ContextScope use RAII pattern - they call Enter() on creation and Exit() on drop. If multiple isolates run on the same thread via async interleaving, the scope stack gets corrupted:
❌ With async interleaving (broken):
Thread: Enter(A) → await → Enter(B) → Exit(B) → Exit(A)
V8 sees: Enter(A) → Enter(B) → Exit(B) → Exit(A) ✗ Wrong order!
✅ With sequential execution (safe):
Thread: Enter(A) → work → Exit(A) → Enter(B) → work → Exit(B)
V8 sees: Enter(A) → Exit(A) → Enter(B) → Exit(B) ✓ Correct! Semaphore prevents queue explosion:
let permit = semaphore.acquire_timeout(10s).await?;
// If timeout: return 503 Service Unavailable Timing Attack Mitigation
performance.now() is rounded to 100µs to prevent timing attacks:
// Returns values like: 1234.5, 1234.6, 1234.7
// NOT: 1234.567891234
performance.now(); Threat Model
| Threat | Attack | Protection |
|---|---|---|
| Memory bomb | Allocate 1GB buffer | ArrayBuffer allocator rejects |
| CPU mining | Infinite loop | CPU timer terminates (100ms) |
| Slow loris | Hold connection forever | Wall-clock timeout (60s) |
| Fork bomb | Spawn processes | No process API exposed |
| Disk fill | Write huge files | No filesystem API exposed |
| Network DoS | Infinite outbound requests | Operations handler controls |
| Timing attack | Measure crypto timing | performance.now() rounded |
| Code injection | eval() malicious code | Code pre-supplied, eval limited |
| Credential theft | Access S3 keys | Bindings resolve server-side |
Configuration
RuntimeLimits
pub struct RuntimeLimits {
pub heap_initial_mb: usize, // Default: 1
pub heap_max_mb: usize, // Default: 128
pub max_cpu_time_ms: u64, // Default: 100 (Linux only)
pub max_wall_clock_time_ms: u64, // Default: 60_000
} Environment Variables
| Variable | Description | Default |
|---|---|---|
WORKER_POOL_SIZE | Thread count | CPU cores |
MAX_QUEUED_WORKERS | Max workers in queue | pool_size × 10 |
WORKER_WAIT_TIMEOUT_MS | Timeout waiting for queue slot | 10000 (10s) |
RUNTIME_SNAPSHOT_PATH | V8 snapshot blob | None |
Audit Checklist
For security auditors:
- V8 heap limits enforced (
runtime/mod.rs) - ArrayBuffer allocator limits external memory (
security/array_buffer_allocator.rs) - CPU enforcer terminates on timeout (
security/cpu_enforcer.rs) - Wall-clock guard terminates on timeout (
security/timeout_guard.rs) - Sequential worker pool ensures one isolate per thread (
worker_pool.rs) - No filesystem APIs exposed
- No child_process APIs exposed
- Fetch routed through operations handler
- Bindings resolve credentials server-side
- Environment object is frozen
-
performance.now()precision reduced to 100µs (web_api.rs) -
SharedArrayBufferremoved from globalThis (web_api.rs) -
Atomicsremoved from globalThis (web_api.rs) -
Atomics.wait()disabled at V8 level (allow_atomics_wait(false))