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 (KillProcess, not EPERM
— a killed process can't probe the sandbox and adapt). 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.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) is unsandboxed on purpose — it is the trusted core that spawns
processes and holds secrets, and it never parses untrusted bytes.
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
└── 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
- 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. Navigation is same-origin per renderer (site isolation). - 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. - 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 embeddings (64:ff9b::7f00:1,::127.0.0.1), 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. It can't resolve hostnames offline; a real one resolves DNS, re-checks the resolved IPs, and pins that IP for the connection to defeat DNS rebinding. - 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 kernel
returns
EPERM. Seesrc/sandbox.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_AS(512 MiB address space),RLIMIT_NOFILE, andRLIMIT_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 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). - 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.
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), the cookie broker ((zone, origin)partitioning + HttpOnly hiding), IPC frame round-trip and oversized-length rejection, the per-source backpressureGate, and origin parsing. The single-process engine is also driven end to end (open → navigate → frame → close → shutdown, cross-origin refusal, 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, 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, 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.
| 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 (socket/local transports), wire messages, bincode framing, SCM_RIGHTS fd-passing |
src/net_daemon.rs |
Net component: serve loop, (synthesized) fetching |
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/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.rs |
seccomp-BPF privilege capping for the child processes (Linux) |
src/selftest.rs |
Sandbox-enforcement probes spawned by the integration tests (Linux) |
src/main.rs |
Child-role dispatch for re-exec + minimal event-driven usage |
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,
KillProcess, W^X viaPROT_EXECargument-filtering). Still missing for a real deployment: a per-arch baseline tested across libc/kernel versions, filesystem restriction (Landlock), and namespaces/pivot_rootfor defense in depth. A real JS JIT needs executable memory, so it would carve out a dedicated JIT exception rather than denyPROT_EXECoutright. macOS/Windows need their own mechanisms (Seatbelt, AppContainer). - 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 classifies IP literals but can't resolve hostnames offline — production resolves DNS, re-checks the result, and pins the IP against rebinding.
- 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 socket writes, so a renderer that floods requests and refuses to read its replies can stall the loop (memory stays bounded — the per-source gates handle that — but responsiveness doesn't). Non-blocking per-channel writes on an async loop fix both. 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, and anhttps:renderer can't be navigated tohttp:. 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), and cross-origin navigation is refused instead of swapping renderers. - 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 is unsandboxed (minimal, trusted, holds no secrets) and reaps its renderers on shutdown; a real one would also seccomp-confine itself around thefork()/fd-passing path. Linux only —fork()-without-exec + the Rust runtime relies onfork()semantics. Elsewhere renderers fall back to direct fork+exec. - Phase 3 (GPU process) is not modeled — structurally it's the same pattern as the net component.