Proof of concept for gosub-io/gosub-engine#1080 "Process Isolation for Security: Multi-Process Architecture": an event-driven engine (commands in, events out — shaped like the real gosub engine) whose components run either as isolated child processes (the issue's architecture) or as threads in a single process (classic engine), over the same component code, IPC protocol, and policy checks.
cargo run --release # multi-process (default)
cargo run --release -- --single-process # same engine, components as threads
cargo build --no-default-features # single-process-only binary
cargo test # unit + integration suite (see Tests)
cargo run --release -- --bench-tiles 500 shm # measure tile transport…
cargo run --release -- --bench-tiles 500 socket # …against the copy path
cargo run --release -- --bench-stream 12 ring # measure body streaming…
cargo run --release -- --bench-stream 12 socket # …against the copy pathEach child installs a seccomp-BPF filter after connecting its IPC link. It is
a default-deny allowlist: the component's legitimate syscalls are
enumerated and everything else is a fatal SIGSYS — the process is killed, not
handed an EPERM it could probe and adapt to. The violation traps through a
SECCOMP_RET_TRAP handler that first names the offending call on stderr
([sandbox] SIGSYS: blocked syscall #N — terminating) and then re-raises
SIGSYS, so termination is exactly as before — the process still dies with the
same signal the selftest probes assert — but you now learn which syscall it
was. That diagnostic earns its keep once V8 lands and the renderer starts
issuing calls we did not anticipate ("renderer died" becomes "renderer tried
openat (#257), killed"). The handler's only added privilege is tgkill,
argument-filtered to SIGSYS-to-self so it cannot poke any other process or
signal. This is fail-closed — a syscall we never considered (a new one, or a
bypass such as io_uring-based networking) is denied for free — which is what
real renderer sandboxes (Chromium, Firefox) do. src/sandbox/linux.rs holds
the curated baseline.
A few allowed syscalls are argument-filtered: mmap/mprotect are
permitted only when PROT_EXEC is clear, so a renderer can never turn writable
memory executable (W^X) — the step most memory-corruption exploits need to
run injected code. Startup is fail-closed too: if the filter can't be
installed, the component aborts rather than run unconfined, so multi-process
mode requires seccomp support (use --single-process where it's unavailable).
The renderer gets the baseline only: no socket/connect (no network), no
openat (no file opens — so the filesystem is capped without Landlock), no
execve/clone (no subprocesses), no io_uring_*. The net component gets the
same baseline plus the socket family, since it owns network access.
The engine (parent) can't be capped to a renderer's allowlist: the privileges
that would drop are exactly the ones it exists to exercise — it spawns
processes, opens sockets, and stamps identity/policy. (It no longer holds the
cookie jar on Linux: that moved to the out-of-process vault — see shrink
the broker below.) What it does carry is a
deny-list seccomp filter (see the broker paragraph below): the broad surface
stays, but the escalation syscalls it never needs are a fatal SIGSYS. It is
not exempt because it is safe from hostile input — it plainly is not. Every
frame a renderer or the net component sends is bincode::deserialized inside the
engine (rx.recv::<FromRenderer>() in the loop's reader threads), so a
compromised child's bytes are parsed in a process with full ambient authority —
the deny-list removes kernel-escalation reach, not the parser's reach. The vault
shrinks what that reach yields (the jar is no longer there); the parsing itself
staying in the broker is a documented accepted risk (below).
What bounds that today: frames are length-prefixed and capped at 16 MiB with
the length checked before allocating, the wire types are closed enums, and
bincode has no type-directed dispatch — it cannot be steered into constructing
arbitrary types the way a gadget-bearing format (pickle, Java serialization,
serde_yaml tags) can. Those parsers are also fuzzed (see Fuzzing). So this is
a narrow surface, not an open one. It is still the sharpest edge in the model,
because the whole architecture rests on the broker being uncompromisable and
this is the one place untrusted bytes reach it.
The structural fix is to shrink the broker — the principle being that no one
process should hold both large secrets and a large hostile-input surface. The
broker parses untrusted frames, so the secrets leave it. The biggest secret is
the cookie jar, and on Linux (multi-process) it now lives in a separate
low-authority vault process (src/vault.rs) — the tightest-confined role in
the model: baseline-only filter (no network, no openat, no ioctl, no exec), so
even if compromised it can only answer the narrow queries get-attachable,
get-visible, set. The vault is net's sole client (net-direct): the net
component queries it for the cookies to attach to an outbound request, keyed by
the broker-stamped (zone, origin), so HttpOnly session tokens never touch
the broker at all, and a broker compromise no longer yields the jar. The broker
keeps only its identity-stamping role, and document.cookie/set route through
net to the vault. It is wired into the service respawn-with-bound machinery (net
reports the vault's death; the broker respawns it and re-binds net's link). The
storage service already followed this shape; cookies were the outlier.
Off Linux, and in single-process mode on Linux (no vault — there is no
address-space isolation to gain), the broker keeps the in-broker jar, so cookie
policy (HttpOnly stripping, (zone, origin) partitioning) is identical in both
modes; cookies_in_vault() selects at runtime. The remaining half of "shrink the
broker" — the untrusted parsing — deliberately stays in the broker as an
accepted risk (safe Rust; hardened by framing/fuzzing and the planned typestate
contract, IPC-TYPESTATE.md) rather than an isolated subprocess; see What a real
renderer would force open and browser-architecture-comparison.md.
The parsing half of that principle is a deliberately accepted risk: the
deserialization stays in the broker rather than moving to an isolated subprocess.
Chromium isolates its parser because it is C++; this one is safe Rust, so the
residual (a bincode/serde soundness bug, a logic bug through a well-typed message,
a bounded DoS) is low and is hardened in-language — bounds + framing + fuzzing
today, and a typestate contract next (IPC-TYPESTATE.md), not an external IDL.
The honest limit: a broker-parser exploit is still full compromise (the broker's
danger is its ambient authority, which the vault does not reduce), so this is a
conscious bet on Rust's memory safety, to be revisited if the parser ever gains
unsafe or a C dependency.
The broker is not left entirely unconfined, though. Like Chromium's browser
process — which is sandboxed, just far more loosely than a renderer — it gets
two loose, best-effort layers. A Landlock sandbox on the filesystem: it may
read and execute anywhere (it must, to spawn children and load their libraries),
but may only write beneath the temp dir — so a broker subverted through the
deserialization surface cannot plant persistence, overwrite its own binary, or
corrupt the user's files. And a deny-list seccomp filter: it keeps the broad
syscall surface it genuinely needs (exec, threads, files, sockets — which is why
a renderer-style allowlist does not fit, and why Chromium's browser process is
not allowlisted either), but the escalation primitives it never uses are a fatal
SIGSYS — ptrace/process_vm_*, kernel-module loading, kexec, bpf,
perf_event_open, userfaultfd, the keyring, and both the classic and the
fd-based mount escapes (mount/setns/pivot_root and
fsopen/fsmount/move_mount/open_tree/…, plus open_by_handle_at). So a
broker compromise can no longer reach for a kernel exploit. Every denied syscall
is also one no child needs, because this filter is inherited before each child
installs its own stricter allowlist. See lock_down_broker in src/sandbox/.
W^X is not a one-off exception — it is the clearest case of a broader pattern: several of the tightest measures here are enforceable only because this renderer is a stub. A production renderer (a real JIT plus a full media/GPU/worker stack) would force each of them open, and then compensate elsewhere — which is exactly what Chromium and Firefox do. Naming them keeps the honest ones honest and stops "we enforce X" from reading as "a real browser could too":
- W^X (
PROT_EXECdenial) — a JIT needs writable→executable memory, anddlopening a media codec or GPU driver maps code executable after the sandbox is on. The fix is a narrow JIT/loader carve-out (one RWX region, or a dual-mappingmemfdRX+RW alias), not blanket RX. (Chromium even disables Intel CET in the renderer for the JIT.) - No threads — the renderer baseline has no
cloneat all, and the fork server filterscloneto a plain fork (noCLONE_THREAD/CLONE_VM). A real renderer is deeply multithreaded: V8's compiler and GC threads, the compositor/raster threads, Web Workers, WASM.clonewith the thread flags has to be allowed — arguably the biggest relaxation after W^X, and easy to miss because this renderer happens to be single-threaded. - The renderer memory cap (axis already fixed) — this was
RLIMIT_AS = 512 MiB, a virtual address-space cap enforceable only because the renderer is JIT-less: V8's pointer-compression cage reserves ~4 GiB up front, so that cap would kill it at init. The PoC now bounds the heap instead —RLIMIT_DATA, which since Linux 4.7 ignoresPROT_NONEreservations — with a generous 16 GiBRLIMIT_ASkept only as a virtual sanity ceiling. On top of that, the engine now places each spawned child in its own cgroup v2memory.max(best-effort — the real physical bound, whose OOM kill is scoped to the offending child rather than the global killer reaching the broker); the rlimit is the self-applied approximation that still applies where cgroup delegation is absent. Chromium likewise does notRLIMIT_ASits renderers. (Per-renderer cgroups are a further step — see the cgroup note insrc/sandbox/linux.rs.) - Zero file opens — a real renderer still needs runtime
openatfor ICU/locale data, fonts, the GPU shader cache,dlopen'd libraries, and/proc/self/maps; "noopenatever" is tighter than reality. It moves to a path broker + Landlock rather than an outright denial. - The
fcntlallowlist is tile-shaped —fcntlis argument-filtered to the seal commands (F_ADD_SEALS/F_GET_SEALS/F_GETFD, plusF_SETFDonly to setCLOEXEC), enforceable only because the renderer does nothing but seal tiles. A real renderer issuesfcntlbeyond these —F_SETFLfor non-blocking fds in async I/O,F_DUPFD_CLOEXECoutside the fork path — so this filter relaxes alongside the threads/JIT ones. - The ephemeral decoder's timeout only reaps a wedged fork-served decoder at
shutdown — on a decode timeout the engine frees the slot, replies
Failed, and drops the socket, but it cannot kill a fork-served child (it isn't the parent; the fork server is), so a decoder compromised into spinning lingers until the fork server dies with its PID namespace. Harmless for the boundedGIMGstub (which can't loop), but a real codec that infinite-loops on hostile input wants an active kill path — the fork server reporting child pids, or a per-(zone,origin)decoder pool it can terminate. - Net component is outbound-only — no
bind/listen, which is right for a fetcher, but WebRTC's ICE/STUN/TURN binds local UDP sockets. Real-time media reopensbind(QUIC is fine — it is connected UDP). - Crash reporting vs. the inbound-debug lockdown (resolved by self-capture)
—
PR_SET_DUMPABLE=0plus the broker'sptrace/process_vm_readvdenial andRLIMIT_CORE=0mean no other process can read a crashed one to build a report. Rather than threading that needle with a privileged handler, the PoC does what Crashpad does on Linux: the crashing process self-captures a scrubbed report (signal + faulting address, no memory contents) from its own signal handler before dying — noptrace, no dumpable relaxation, no core. See "Crash reporting" below.
For calibration, what survives contact with a real browser and stays as-is: no
raw sockets in the renderer and its empty network namespace, no execve
anywhere, the brokered network and HttpOnly stripping, default-deny seccomp as a
posture (the allowlist just grows with V8), and the clone3→ENOSYS zygote
model. So W^X belongs to a class of stub-only guarantees — each relaxed, then
compensated for — not a lone asterisk.
Mirroring gosub-engine's EngineCommand/TabCommand/EngineEvent shape
(src/events.rs): you send commands through an EngineHandle and react to
events from a channel — nothing blocks, and frames from different tabs arrive
in whatever order the renderers finish.
let (engine, events) = engine::start(mode);
engine.open_tab("https://example.com")?;
for event in events {
match event {
EngineEvent::TabOpened { tab_id, origin } => engine.navigate(tab_id, url)?,
EngineEvent::FrameReady { tab_id, tile } => { /* composite */ }
EngineEvent::TabCrashed { tab_id } => { /* only that tab died */ }
...
}
}Commands: OpenTab, Tab { Navigate | Close }, SetCookie, Shutdown.
Events: TabOpened, FrameReady, NavigationFailed, TabCrashed,
TabClosed, EngineShutdown, …
Internally (src/engine.rs) the engine is one event-loop thread with a
single inbox — the std-only equivalent of the real engine's tokio::select!
worker loop. Cheap reader threads forward every message source into it:
EngineHandle ── EngineCommand ──▶ ┌────────────┐ ──▶ EngineEvent
│ event loop │
renderer/net reader threads ────▶ └────────────┘ ──▶ replies to components
Because the loop never blocks on any one component, fetches for many tabs are
multiplexed over the single net link with request ids (pending_fetches
maps each reply back to the tab that asked).
engine event loop (broker — owns cookie jar & policy)
├── net component Phase 1: sole owner of network capability
├── storage service filesystem: per-(zone,origin) key/value store
├── font service filesystem: opens font files, returns metrics
├── audio service device stub: confined with an ioctl filter
├── gpu service device stub: confined with an ioctl filter
└── fork server (Linux) minimal, single-threaded, secret-free
├── renderer (zone, A) Phase 2: per-(zone,origin), unprivileged
├── renderer (zone, B) Phase 2: per-(zone,origin), unprivileged
└── decoder ephemeral: forked per image, decodes one, exits
Two families of child, split by one rule: the zygote can only parent a process strictly less privileged than itself. Its filter, empty netns and non-dumpable flag are inherited and only narrow, so anything needing a capability the zygote gave up cannot be its child.
-
Under the fork server (content processes, less privileged): renderers, and the ephemeral decoder. They fork cheaply from the warm zygote.
-
Off the engine (services, each needing a capability renderers lack): the net component (network), storage and font (
openat), audio and gpu (deviceioctl). Each is spawned fork+exec with its own filter — a superset of the content baseline — and, except the net component, an empty netns. -
Image decoding runs in a throwaway process. Decoding is the most dangerous input a browser handles (libwebp CVE-2023-4863 was a zero-click RCE in every major browser), so renderers never parse image bytes themselves — they broker a
NeedDecodeto a decoder forked from the zygote, which decodes exactly one image and exits. It is a content process with the renderer's confinement (no network, files, or exec), so a parser bug is contained; a crash is relayed to the renderer as a decode failure, never a crash of anything else. The wait for its reply is time-bounded (DECODE_TIMEOUT): a decoder wedged by the image it parsed can't pin the engine's reader thread or hold the tab's decode slot forever — it times out to a decode failure, the socket is dropped, and the process is reaped. It is deliberately ephemeral, not shared: holding no state, a decoder can never see a second origin's image — a single long-lived decoder would reintroduce the cross-origin channel the per-(zone,origin)split closes. The per-image fork is what the warm fork server makes cheap. -
Filesystem-capable services are separate processes with a wider filter, path-confined by Landlock. Renderers deny
openatoutright — the property that caps their filesystem — which is only sustainable while nothing renders real text or persists data. So storage (thelocalStorage/IndexedDBstand-in) and the font service run outside the zygote with abaseline + openatfilter. That grantsopenaton any path, because seccomp sees only the syscall number, never the path pointer — so Landlock confines which paths: each service declares a ruleset of(directory, rights)and the kernel enforces it, scoping storage to its own dir and the font service to its one read-only file. Storage is additionally keyed by the(zone, origin)the engine stamps (never a message claim), and the renderer's key is hashed into the filename rather than spliced into a path — so path traversal is guarded at the application level and by the kernel. That filename hash is keyed with a per-run random secret (SipHash), because the key is renderer-controlled: an unkeyed invertible hash would let a compromised renderer craft a key whose filename collides with another origin's slot (a cross-origin read/write), while a keyed PRF it cannot observe makes the collision unconstructible. It is also byte-bounded: each value is capped (MAX_VALUE_BYTES) and the store is held to a lifetime budget (MAX_STORE_BYTES) tracked with an in-memory running counter — accounting an overwrite as a delta, and needing no directory-enumeration syscall — so a renderer can't fill the host disk one boundedSetat a time. Landlock is best-effort: a kernel without it degrades to seccomp + the hashing, rather than refusing to start. GPU and audio are intentional stubs, by scope: real processes with the correct device filter (baseline + openat + ioctl) and empty net/IPC/UTS namespaces, which proves the security-relevant thing — GPU/audio can run out of process and confined. The actual graphics work is deliberately out of scope: a PoC has no hardware to drive, and compositing is not a security demonstration. The honest caveat (inherent to real GPU work, not a gap to close) is thatioctlis a large surface seccomp constrains poorly, so the isolation shown is the process boundary, not a tight filter. -
Renderers hold no secrets: cookies and network access live in the engine and net component. A renderer can only send IPC messages, and every message is policy-checked in the event loop (
tab_request). -
Identity is
(zone, origin), ambient and not claimed. A zone is a storage/cookie partition (browser profile / container tabs — "Work", "Personal"), matching gosub's ownZoneconcept; the engine keys its cookie jar by(ZoneId, origin), and a renderer process is bound to one(zone, origin). So the same origin opened in two zones runs as two separate processes with independent cookie jars — one can never touch the other's partition. The engine knows each tab's(zone, origin)because it spawned the renderer; identity fields inside messages are never trusted. A renderer only ever serves its own origin: a cross-origin navigation swaps the renderer (site isolation) — the engine tears the old one down and brings up a fresh process bound to the new(zone, origin), the way Chromium changesRenderFrameHost, rather than letting one process serve two origins. The teardown is distinguished from a crash (the tab's gate is closed first) so the reused tab id never surfaces a spuriousTabCrashed. Because the swap reuses the tab id, each renderer generation carries a monotonic epoch: a message the old renderer had already queued is dropped rather than processed against the new origin — otherwise it would be stamped with the new(zone, origin)and, for a storage write, land in the new origin's partition. -
HttpOnly cookies never reach a renderer. Cookies carry an
http_onlyflag; the net component receives all of a request's cookies to attach to the outbound fetch (it must — that's how authenticated requests work), but a renderer asking fordocument.cookiegets only the non-HttpOnly ones. So an exploitedexample.comrenderer never seesexample.com's session token — it travels engine → net and skips the renderer's address space entirely. -
Cross-origin subresources go through Opaque Response Blocking (ORB). A renderer's document fetch (
NeedFetch) is same-origin only, but real pages load cross-origin subresources (images, scripts, styles, fonts), so aNeedSubresourcerequest may be cross-origin. That is safe only because the trusted side decides what bytes the renderer may read: site isolation keeps each origin in its own process so a Spectre gadget reads only its own address space, and ORB is what keeps cross-origin secrets from getting into that space to begin with. The engine resolves the destination origin and attaches its cookies (never the renderer's), then the net component classifies the response and applies ORB (src/orb.rs): a same-origin or CORS-approved response is readable; a cross-origin no-cors embeddable type (image/script/CSS/font) is delivered opaque (usable, not readable as data); a cross-origin data type (HTML/JSON/XML) or anything not clearly embeddable is blocked — its bytes never enter the renderer. Cross-origin navigation is handled by swapping the renderer (above); ORB is the separate mechanism for cross-origin subresources, which a page loads without navigating. -
SSRF policy is centralized in the net component (the one place allowed to open sockets), so no renderer bug can bypass it. It classifies the numeric address (loopback, private incl.
172.16/12, link-local/cloud-metadata, CGNAT,0.0.0.0/8, multicast, class E, the special-purpose registry blocks — TEST-NETs, benchmarking,192.0.0/24, 6to4 relay — and the IPv6 equivalents incl. unique-local and link-local), so it isn't fooled by alternate IP encodings (http://2130706433/,0x7f.1, octal), IPv4-mapped IPv6, NAT64/IPv4-compatible/6to4 embeddings (64:ff9b::7f00:1,::127.0.0.1,2002:c0a8:0101::), userinfo confusion (http://real.com@127.0.0.1/), or a trailing dot. Subnet-directed broadcast (x.y.z.255) is knowingly not classified — it depends on the local netmask, and refusing every.255would break legitimate public hosts. Hostnames resolve through a pluggable resolver seam (the PoC's is synthetic and offline; a deployment selectsSystemResolver): every resolved IP is classified and the survivor is pinned as the address to connect to, so there is no second lookup left to poison (DNS rebinding). Redirects are followed with the same classification re-run on every hop — an open redirect to169.254.169.254is refused even when the entry URL was public, and the chain is bounded so a redirect loop terminates as a refusal. A redirect that leaves the original origin drops the request's cookies rather than leaking one origin's session token to another host. -
Renderers are sandboxed at the OS level (Linux): after connecting their IPC link, they install a default-deny seccomp-BPF allowlist permitting only a curated baseline (I/O on existing fds, memory, futex, signals, time). A renderer — even one fully code-exec'd by an exploit — physically cannot open a socket, an io_uring instance, a file, or a subprocess: the attempt traps to
SIGSYS, is logged with the syscall number, and kills the process. Seesrc/sandbox/linux.rs. The net component gets the same baseline plus the socket family. -
Children run under OS resource caps the engine sets at spawn (Linux):
RLIMIT_DATA(512 MiB committed heap — the heap, not the address space, so a future JIT's multi-GiB virtual cage still fits) with a generous 16 GiBRLIMIT_ASsanity ceiling, plusRLIMIT_NOFILEandRLIMIT_CORE=0. seccomp caps what a child may do; these cap how much, so a compromised renderer can't exhaust host memory/fds — an over-allocation aborts that process, not the machine — and a crash won't dump a core full of secrets. On top of the rlimits, the engine places each spawned child in its own cgroup v2memory.maxwhere the platform allows it (a systemd scope withDelegate=yes, or root) — a true RSS bound whose OOM kill is scoped to the offending child rather than the global killer reaching the broker; it degrades to rlimits-only in a shared scope. This is the parent-sideconfine_spawned_childseam, the Linux analogue of the Windows job-object memory cap. At shutdown the broker tears its cgroup subtree back down (removing the per-child leaves it can, the rest reclaimed by the enclosingDelegate=yesscope) rather than orphaning it under/sys/fs/cgroup. -
Crash reporting without a core dump or
ptrace:RLIMIT_CORE=0stops cores,PR_SET_DUMPABLE=0and the broker deny-list stop any other process reading a crashed one — so, like Crashpad on Linux, the crashing process self-captures. A handler forSIGSEGV/SIGABRT/SIGBUS/SIGILL/SIGFPE(on an alternate stack, so a stack overflow can still run it) writes a one-line report — signal + faulting address, no memory contents, so it can't leak the cookie jar even from the broker — then restoresSIG_DFLand returns, so the fault re-executes and the process still dies with its signal (the engine's crash detection is unchanged). Uses onlywrite+sigaction, both already on every filter. -
Crash-loop guard: an origin that crashes its renderer 3+ times in 30 s (per
(zone, origin)) is refused a fresh one —OpenTabFailed/NavigationFailedinstead of respawning into a loop — with the backoff expiring as the crashes age out. Bounds respawn churn, the complement to the live-renderer cap. -
On the IPC side, the shared event-loop inbox is bounded per source. Every component (each renderer, the net process) may have at most
MAX_QUEUED_PER_SOURCEmessages queued-but-unprocessed: its reader thread takes a permit before forwarding a message and the loop returns one after handling it. When a source runs out of permits its reader stops draining that socket, so the OS backpressures the component itself. Because the bound is per source, one compromised renderer flooding any message type pins a fixed slice of engine memory and can't crowd out other tabs — without it, a flood grows the engine ~90 MB/s to OOM; with it, engine RSS stays flat. In-flight fetches are additionally bounded per tab (MAX_INFLIGHT_FETCHES), and decodes per tab (MAX_INFLIGHT_DECODES, since each forks a process). -
The engine also caps the total live renderer count (
MAX_RENDERERS). The per-tab bounds limit what one renderer costs; nothing else limits how many renderers a hostile page (window.openin a loop) or a buggy embedder can bring into being. Past the cap anOpenTabis refused (OpenTabFailed) rather than spawning another process, so tab count can't become a PID/memory exhaustion vector — the same finite ceiling Chromium's process limit imposes. -
A crashed renderer surfaces as
EngineEvent::TabCrashedfor that tab only; the engine and all other tabs keep running (in multi-process mode). -
Children are reached via an inherited
socketpair(2)fd, not a socket on disk. Possessing the fd is the authentication — it cannot be forged — so there is no rendezvous path, no auth token on argv (which any local user could read from/proc/<pid>/cmdline), and noaccept()race. Every other fd the engine holds staysCLOEXEC, so one renderer never inherits another's channel. -
Renderers are created by a fork server (Linux), the way Firefox (a "fork server") and Chromium/Android (a "zygote") do it — see below.
Renderers are not exec'd from scratch; they are fork()ed without exec
from a dedicated fork server process, so each new renderer inherits an
already-initialized runtime copy-on-write instead of re-running full process
startup. Two reasons drive it, one speed and one safety:
- Speed —
fork()withoutexec()skips re-linking and re-initializing the runtime for every content process (the dominant win in a real browser). - Safety — you fork from a minimal, single-threaded, secret-free snapshot. The engine can't be that snapshot: it is multithreaded (forking it would strand locks other threads hold) and it owns the cookie jar (a fork would inherit it). So the fork server exists solely to be a clean thing to fork from. It is brought up before the engine loads any cookies.
The engine still creates each renderer's socketpair, keeps one end, and passes
the other to the fork server via SCM_RIGHTS fd-passing — so the renderer
talks straight to the engine even though the fork server is its OS parent. The
forked child then drops privileges (its own seccomp filter; rlimits inherited
from the fork server) and serves. Crash detection is unchanged: the engine holds
the renderer's socket end, so a dead renderer still surfaces as TabCrashed
regardless of which process is its OS parent; the fork server reaps the corpse.
Because it is the OS parent of every renderer and (short-lived) decoder, the
fork server sets SA_NOCLDWAIT so the kernel auto-reaps them as they exit —
otherwise a long session's exited decoders and crashed renderers would pile up
as zombies until it shut down.
You can see it in a syscall trace: only fork-server and net-daemon are ever
execve'd — the renderers appear only as clone()/fork() from the fork
server, with no exec. The net component (a one-off) is still spawned directly.
In multi-process mode a rendered tile is not copied through the socket: the
renderer rasterizes into a sealed memfd and passes the fd over the
existing SCM_RIGHTS channel; the engine maps the same physical pages
read-only and hands the zero-copy view to the compositor
(TilePixels::Shared). Only a ~10-byte TileShm { width, height } message
travels in-band. This is the channel OOPIFs and a future decode process would
reuse. src/shm.rs holds both sides; the lifecycle discipline:
- Producer seals before sending. The renderer writes the tile, unmaps, and
seals
F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE | F_SEAL_SEAL— the kernel refusesF_SEAL_WRITEwhile any writable mapping exists, so a sealed fd proves no writer remains anywhere. There is no window where both processes can write the same pages, and the seals can never be lifted. - Consumer validates the fd, not the message. The dimensions in the
message are a claim: the engine bounds them (≤ 2048², i.e. 16 MiB — the
same per-message ceiling the in-band frame cap imposes, so shared memory
never lets a renderer pin more engine memory per message than the socket
path could), requires the seals to actually be present (
F_GET_SEALS), andfstats the fd's real size before mapping.F_SEAL_SHRINKmakes that check TOCTOU-free — a malicious renderer can't shrink the fd after validation toSIGBUSthe engine. A tile that fails validation is a protocol violation: the engine drops the link (→TabCrashed). - No fd leaks — including smuggled ones. The memfd is
MFD_CLOEXEC, received fds areMSG_CMSG_CLOEXECand wrapped inOwnedFd, the producer's copy closes right after sending, and the consumer's closes as soon as the mapping exists (dropping theTileunmaps). The receive side (ipc::recv_fd) walks all control messages, adopts every fd the kernel installed, and enforces exactly-one — a peer stuffing extra fds into the hand-off (sendmsgis on its allowlist) gets a refusal and every fd closed, instead of silently leaking descriptors into the engine's fd table until it's exhausted. One sealed memfd per tile; a real compositor at 60 fps would switch to a reusable buffer pool, which must tradeF_SEAL_WRITEfor fence-based ownership handoff (seesrc/shm.rsdocs). - The renderer's sandbox allowlist grows only
memfd_create,ftruncate, andfcntlargument-filtered to the seal commands —memfd_createopens nothing on the filesystem, and every mutatingfcntl(e.g.F_DUPFD,F_SETFDclearingCLOEXEC) is still a fatalSIGSYS, which the selftest probes verify from outside.
Measured, not assumed (--bench-tiles <frames> <shm|socket>, release build,
500 × 512²×4 tiles over one tab, every tile byte-compared against the expected
pattern): 2.22 ms/frame via shared memory vs 4.90 ms/frame copied through
the socket (2.2×), with engine peak RSS 3.6 MB vs 4.6 MB — the ~1 MiB tile
no longer materializes in the engine at all. Single-process mode (and any
shm failure) falls back to the in-band copy; the consumer-side validation
doesn't care which path was taken.
Where a tile is complete-and-immutable (seal everything), a download is a
stream — so large fetch bodies use the other end of the shared-memory dial:
a fixed ring buffer (src/ring.rs, 256 KiB window) that the net component
keeps writing while the renderer keeps reading, wrapping at the end — pipe
semantics without the kernel copy, which is what Chromium's data pipes are.
The engine brokers but never touches the bytes: it routes the in-band header
(FetchBodyStream { status, body_len }) and forwards the ring fd to the
requesting renderer, so body bytes flow net → renderer directly. What the
ring buys:
- Constant memory for unbounded data. The transport holds one window, not one body: a 128 MiB body streams through the 256 KiB ring (wrapping ~512 times) with every process's RSS flat. The 16 MiB IPC frame cap stays untouched — it still bounds messages; bodies no longer ride in messages.
- Structural backpressure. A full ring blocks the producer, an empty one blocks the consumer; nobody buffers on the other's behalf (a real net component's stalled writes would close the TCP window back to the origin). Both sides bound their patience (5 s of zero progress = abandon the stream), so a dead or deliberately-stalling peer costs seconds, not a hung process — and only that stream, never the component.
- The trust contract shifts from seals to discipline — deliberately, per
transport role. The kernel still guarantees size (
F_SEAL_SHRINK|GROWare applied at creation; unlikeF_SEAL_WRITEthey coexist with writers, so the consumer'sfstatcheck stays TOCTOU-free and no read canSIGBUS). Contents and cursors are hostile: each side copies the shared read/write cursors to locals and validates them against capacity before touching a byte (a corrupt cursor is a detected protocol violation, not an OOB read), offsets are reduced mod capacity only after that check, and the consumer reads single-pass — every byte copied out exactly once, never re-read — which is the discipline that replaces immutability. Wakeups are shared futexes on the cursor words; a producer that dies mid-stream is caught by an abort flag, a truncated stream (fewer bytes than promised) is an error. - Same lifecycle hygiene as tiles:
MFD_CLOEXEC/MSG_CMSG_CLOEXEC,OwnedFdeverywhere, producer drops its fd right after sending, consumer maps then closes,F_SEAL_SEALstops the peer from adding seals. No new syscalls in the sandbox — memfd/seals/futex were already in the baseline, and theringselftest probe proves the full dance under renderer lockdown.
The demo exercises it (the personal-zone tab fetches /blob/4, a synthesized
4 MiB patterned body; the renderer byte-verifies and reports the transport),
and it is measured (--bench-stream <MiB> <ring|socket>, release build):
12 MiB in 20 ms (596 MiB/s) via the ring vs 134 ms (90 MiB/s) copied
through the socket — 6.6× — with engine peak RSS 2.7 MB vs 27 MB, since
the socket path materializes the body in the engine twice (net reply + tab
forward) while the ring path never lets it exist there at all. A 128 MiB body
(impossible in-band) streams at 608 MiB/s with the engine flat at 2.6 MB.
Small responses stay in-band on purpose — a ring costs setup; a few-KB page
does not earn it — as does everything in single-process mode (same address
space, nothing to share).
The same trick as Chromium's --single-process: components are written once,
only transport and spawning differ.
- Compile time — the
multi-processcargo feature (default on) gates all process-spawning and Unix-socket code.--no-default-featuresproduces a single-process-only engine, e.g. for platforms without fork/UDS (WASM would be the real motivation in gosub). - Run time — when the feature is compiled in,
--single-processselects the thread-based setup;--multi-process/no flag selects isolation.
The seam is ipc::Endpoint: send/receive halves (EndpointTx/EndpointRx,
splittable so the event loop can hand the receive half to a reader thread)
over either UnixStream or in-process channels, both carrying identical
length-framed bincode messages (with a max-frame check so a corrupt length
prefix can't force an unbounded allocation). Components expose a
transport-agnostic serve(Endpoint, ...) loop; the feature-gated run()
wrappers are only the child-process entry points. The engine's Spawner
either spawns a thread wired with local_pair(), or (multi-process) hands the
child one end of a socketpair(2) — the net component by fork+exec, renderers
by asking the fork server to fork() them. It is the only code that knows
which mode is active.
Note: in single-process mode the policy checks still run, but a compromised renderer thread shares the engine's address space — the checks only become a real security boundary with a process behind them.
cargo test runs two layers:
- Unit tests (in
src/) cover the pure policy/logic deterministically: the SSRF classifier (internal ranges, alternate IP encodings, IPv6, userinfo/trailing-dot bypasses), redirect following (per-hop SSRF re-check, the hop-count bound, and cookies not crossing an origin), Opaque Response Blocking (same-origin/CORS readable, cross-origin embeddable opaque, cross-origin data blocked, and a cross-origin redirect forcing ORB), the cookie broker ((zone, origin)partitioning + HttpOnly hiding), IPC frame round-trip and oversized-length rejection, the per-source backpressureGate, the storage quota admission (per-value cap, overwrite-as-delta, saturating arithmetic), and origin parsing. The single-process engine is also driven end to end (open → navigate → frame → close → shutdown, the cross-origin renderer swap committing the new origin and rendering it, unparseable URL) — the broker/policy code is identical in both modes, so this exercises the real thing. - Integration tests (
tests/integration.rs) run the actual built binary: multi- and single-process runs render and shut down cleanly (the default run also performs a cross-origin renderer swap with real child processes and no spurious crash), unknown args are rejected, tiles arrive via shared memory (multi-process) or in-band copy (single-process) and byte-match the expected pattern either way, the tile bench completes on both transports, large fetch bodies stream through the ring (multi-process) or fall back in-band (single-process) and byte-match the producer's pattern either way, the stream bench completes on both transports, and (Linux) the children both announce and enforce their seccomp sandbox — theselftestprobes confirm that making memory executable (PROT_EXEC), opening a socket, and anyfcntlbeyond the seal commands are each killed bySIGSYS, that the fork server can fork but acloneunsharing a namespace is killed, that a filesystem service'sopenatis scoped by Landlock, and that the broker's Landlock confines its writes to the temp dir (a write outside isEACCES, with a control proving it worked before lockdown) — while the sealed-memfd tile dance and the ring produce/consume dance both survive. Theshmandringunit tests additionally pin the consumer-side refusals: unsealed fds, undersized fds, absurd dimensions/lengths, corrupt ring cursors, aborted and truncated streams — plus a two-thread ring round-trip that wraps the window 256×.
Two properties are checked by hand rather than in cargo test, as they need
external tooling: the fork server forking renderers without exec (an execve
strace shows only fork-server/net-daemon, never renderer) and the
per-source inbox bound holding engine RSS flat under a message flood (RSS
sampling: ~2.8 MB steady vs. ~90 MB/s growth without it). The Gate unit test
covers the bounding mechanism itself deterministically.
The three surfaces where untrusted bytes meet a parser have cargo-fuzz
targets in fuzz/, each importing the real code from the library crate:
decode_image—decoder::decode, the image parser (the libwebp CVE-2023-4863 lineage: a header that lies about its dimensions).ipc_frame—ipc::recv_msgfor the frames a compromised child sends the broker, which deserializes them in its own address space with full authority over every secret (the broker's deny-list seccomp removes escalation syscalls but not this data reach — the sharpest edge in the model, see the sandbox section).ssrf_url—ip_utils::resolve_and_pin, the URL/host/IP-literal parsing that gates every outbound fetch; a mis-parse there is an SSRF.
cargo +nightly fuzz run decode_image # or ipc_frame / ssrf_urlEach target's contract is total: any input returns Ok/Err, never panics
or reads out of bounds. So that the property is also checked in ordinary CI
without nightly, each parser additionally carries a deterministic
*_never_panics_on_arbitrary_* unit test — a seeded xorshift stand-in for the
fuzzer (50 000 inputs each) that pins a regression floor; the fuzz/ targets
explore far more.
| File | Contents |
|---|---|
src/events.rs |
Public vocabulary: EngineCommand, TabCommand, EngineEvent, TabId, Tile |
src/engine.rs |
start(mode), EngineHandle, the event loop (broker + policy), Spawner |
src/ipc.rs |
Endpoint tx/rx halves (channel/local transports), wire messages, bincode framing, SCM_RIGHTS fd-passing (Linux) |
src/channel/ |
Transport seam: the duplex byte channel a link runs over — unix.rs (socketpair), windows.rs (anonymous pipe pair) |
src/net_daemon.rs |
Net component: serve loop, (synthesized) fetching, redirect following, ORB enforcement |
src/orb.rs |
Opaque Response Blocking: the pure decision for what cross-origin response bytes may reach a renderer |
src/ip_utils.rs |
SSRF policy: URL host extraction, IP-literal parsing (incl. inet_aton encodings), blocked-range classification |
src/renderer.rs |
Per-(zone,origin) renderer: serve loop, placeholder render pipeline |
src/decoder.rs |
Ephemeral image decoder: bounds-checked GIMG parser, decodes one image and exits |
src/storage.rs |
Storage service: per-(zone,origin) key/value store, keys hashed (per-run keyed SipHash) into filenames |
src/font.rs |
Font service: opens a font file, returns only metrics |
src/device_service.rs |
Audio + GPU stubs: confined with a device filter, no real work |
src/fork_server.rs |
Fork server (Linux): fork()s renderers without exec |
src/shm.rs |
Shared-memory tiles (Linux): sealed-memfd producer + validating consumer |
src/ring.rs |
Shared-memory ring (Linux): streams large fetch bodies, futex wakeups, hostile-cursor validation |
src/sandbox/ |
Privilege capping seam: linux.rs (seccomp-BPF, netns, rlimits), macos.rs (Seatbelt), unsupported.rs (no-ops) |
src/selftest.rs |
Sandbox-enforcement probes spawned by the integration tests (Linux) |
src/lib.rs |
Library crate: pub modules the binary, tests, and fuzz targets all build on |
src/main.rs |
Binary: child-role dispatch for re-exec + minimal event-driven usage |
fuzz/ |
cargo-fuzz targets over the untrusted-input parsers (decode_image, ipc_frame, ssrf_url) |
tests/integration.rs |
End-to-end tests running the built binary (both modes + sandbox) |
The security mechanisms are real (see the isolation section); what's simplified is the surrounding browser. What each entry below still needs:
-
Sandboxing: the seccomp filter is production-shaped (fail-closed allowlist, SIGSYS-kill-on-violation with the blocked syscall reported, W^X via
PROT_EXECargument-filtering), and renderers additionally run in an empty network namespace (plus IPC, UTS, and PID namespaces) — unshared on the fork server at spawn and inherited by every renderer itfork()s, so "a renderer cannot reach the network" no longer rests on the syscall allowlist alone. The two layers fail independently: an allowlist gap is survivable when the namespace has no interfaces to connect through. The net component is the one role that keeps the host netns; the IPC and UTS namespaces are defense in depth for properties seccomp also covers (no shared System V IPC, its own hostname). The PID namespace is the same kind of belt-and-suspenders forkill/ptrace's absence — a renderer can't even name the broker or host processes by pid. Becauseunshare(CLONE_NEWPID)places the caller's children (not the caller) in the new namespace, the fork server's renderers share one, and the fork server pins its PID 1 with a do-nothing placeholder so one renderer exiting can't tear the namespace down andSIGKILLits siblings (fault isolation). Per-renderer PID namespaces are blocked by the sameuid_map-less-userns constraint as the mount namespace; all of this is best-effort and falls back to the rest where a kernel refusesCLONE_NEWPID. Separately, every process (engine included, in both modes) clears its dumpable flag, so other software running as the same user cannotptrace-attach or read/proc/<pid>/mem— the engine's cookie jar is the obvious target, and this is the inbound direction that seccomp has no say over. It is set afterexecve, which resets the flag; it survivesfork, so renderers inherit it from the fork server. Filesystem restriction with Landlock is used by the filesystem services (storage, font) to path-confine theiropenat, and the broker gets a loose Landlock too (read/exec anywhere, write only the temp dir) plus a deny-list seccomp filter (allow by default,SIGSYSon the escalation syscalls it never uses —ptrace, kernel-module loading,kexec,bpf, and both the classic and fd-based mount escapes,mount/setnsandfsopen/fsmount/move_mount/…). Renderers also get a PID namespace now (shared across the fork server's renderers, with a pinned PID-1 placeholder so one renderer exiting can'tSIGKILLits siblings) — so a renderer can't name the broker or host by pid. Per-renderer PID namespaces and an empty-root mount namespace remain deliberately not added, both blocked by the same concrete reason rather than merely unimplemented: each needs capability over a namespace owned by a user namespace the fork server does not control, and its deliberately-unmapped (uid_map-less) user namespace confers none — while writing auid_mapto fix that is blocked both by AppArmor on modern hosts and by the broker Landlock (/proc/self/uid_mapis outside the temp dir). It is documented atisolate_networkinsrc/sandbox/linux.rs, and seccomp'sopen/openatandkill/ptracedenials cover the properties regardless. Also still wanted: a per-arch seccomp baseline tested across libc/kernel versions. And several of the tightest limits here (W^X, no renderer threads, the 512 MiBRLIMIT_DATAcommitted-heap cap, zero file opens, the seal-onlyfcntlallowlist) are enforceable only because this renderer is a stub — a real JIT-and-media renderer relaxes each and compensates elsewhere; see What a real renderer would force open above.Platform status. Linux is the reference implementation: seccomp, empty net/IPC/UTS/PID namespaces, rlimits, non-dumpable processes, broker Landlock + a seccomp deny-list, best-effort per-child cgroup v2
memory.max, self-captured scrubbed crash reports, an out-of-process cookie vault (the jar out of the broker, net-direct), 23 probes. macOS runs a Seatbelt(deny default)profile with 13 probes — including path-scoped file services (storage/font getsubpathread/write grants for their own directory plus a broadfile-read-metadataso path lookup resolves, while contents outside the scope stay unreadable) and a denied Mach bootstrap (nomach-lookupreach to WindowServer/launchd services). Windows spawns over a pair of anonymous pipes (seesrc/channel/) and installs process mitigation policies — no dynamic code (the W^X analogue), no child processes, no injection extension points, plus win32k lockdown — with 4 probes, plus the parent-side access controls below.Windows has both halves of a sandbox now. The self-applied half is the mitigation policies above (plus low integrity and a job-object memory cap). The parent-side, object-confining half is an AppContainer — the "lowbox" token UWP apps and Chromium's renderer run under — attached at
CreateProcessvia aSECURITY_CAPABILITIESattribute: a per-role container gives a renderer no network and no broad file access, the net componentinternetClient, and each filesystem service access to only its own path (with a Low-integrity relabel so the lowbox can write it) — the same renderer/net split and per-service file scoping Linux gets from seccomp+netns and Landlock. It is env-gated (GOSUB_WIN_APPCONTAINER) rather than default-on for one concrete reason: a lowbox process can only load images the filesystem grants an app-package SID, so the binary must sit at an app-package-accessible install location (C:\ProgramData,C:\Program Files) — exactly what a real installer targets, and what CI'starget\dir is not. With that, it is validated end to end on Windows 11 (registered containers, the capability split, and storage/font round-tripping under the lowbox). The restricting-SID token stays out for the same image-loading reason; the AppContainer is what actually clears that wall. Seesrc/sandbox/windows.rs.For a side-by-side of exactly where macOS and Windows lag Linux — and which gaps are portable (the cookie vault, the crash reporter, the zero-copy transports) versus genuine platform limits (macOS has no hard memory cap, the fork-server zygote is Linux-only) — see
PLATFORM-PARITY.md.Note the netns is obtained via
CLONE_NEWUSER | CLONE_NEWNET(an unprivilegedCLONE_NEWNETalone needsCAP_SYS_ADMIN) and the uid map is deliberately left unwritten, so children run as the overflow uid. This makes multi-process mode require unprivileged user namespaces, the same way it already requires seccomp — hosts without them use--single-process. -
Fetching: synthesized responses instead of real HTTP; the net component handles one request at a time (the engine doesn't block on it, but a real daemon would fetch concurrently — with the ring transport that matters more, since one slow-draining body stream now occupies the component until it completes or hits the 5 s stall timeout). The SSRF filter resolves through a resolver seam and pins the result (the PoC's resolver is synthetic; a deployment selects
SystemResolver), and redirects are followed with the classifier re-run on every hop — but real DNS and real HTTP are still stubbed. -
Event loop & writes: std threads + mpsc instead of tokio; the real engine's worker loops are
select!-based async tasks. The loop's replies to components are blocking writes bounded byREPLY_WRITE_TIMEOUT(5 s) — a socket timeout on unix, aCancelIoExwatchdog on the Windows pipe — so a renderer that floods requests and refuses to read its replies is dropped after that window rather than wedging the loop forever (memory stays bounded — the per-source gates handle that — but responsiveness suffers during the window). Non-blocking per-channel writes on an async loop remove the window. Relatedly, the per-source gate bounds what sits in the engine loop's inbox, butFrameReadyevents ride an unbounded channel to the embedding application — a tile's gate permit is returned when the loop forwards it, not when the app drops it, so an app that stops draining events accumulates tiles (capped at 16 MiB each). A real engine bounds its compositor queue and recycles tile buffers. -
Tile transport: implemented over sealed shared memory (see above). What a real compositor still needs: a reusable buffer pool instead of one memfd per tile (fd churn at 60 fps), which trades
F_SEAL_WRITEfor fence-based ownership handoff, plus damage rects and a swapchain-style acquire/present protocol. -
Origins: the engine's
origin_ofnow canonicalizes the fullscheme://host[:port]tuple (default ports folded), so different schemes or ports are different origins — the cookie jar is partitioned by scheme too, closing the HTTPS→HTTP secure-cookie downgrade. A cross-origin navigation (including anhttps:→http:scheme change) swaps the renderer rather than being refused, which is the real site-isolation mechanism — but a simplified one: it swaps a single-frame tab, not the frame tree, so the genuinely hard parts (out-of-process iframes,document.domainagent clusters, BrowsingInstances, back-forward cache) are still absent. Also still not a real URL parser (no IDNA, no userinfo; the SSRF filter'shost_ofremains the deliberately-hostile one — a real engine would share one implementation). -
Fork server: it is
exec'd fresh (one exec) rather than forked from the engine early to inherit the engine's warm libraries; the modeled behavior is renderers fork-without-exec from a warm process. It confines itself like any other role — its own seccomp filter (a superset of the content baseline:fork/clone/wait4/prctl/seccomp), theclone3→ENOSYS+ plain-forkclonehardening so it cannot unshare a namespace or thread/VM-share viaclone, empty net/IPC/UTS namespaces, and non-dumpable — all inherited by the renderers it forks — and it is minimal and secret-free besides. Linux only —fork()-without-exec + the Rust runtime relies onfork()semantics. Elsewhere renderers fall back to direct fork+exec.