Security model
bext-lite has two deployment shapes, and its security model serves both:
- A trusted single app you ship as a binary, desktop app, or mobile app.
- An untrusted tenant on a public-proxied multi-tenant host, where a tenant edits and publishes real PRISM/React code and a web visitor may be hostile.
The second shape sets the bar. Everything below holds for it, which means it more than holds for the first. The whole point of bext-lite as the untrusted-hosting substrate is that the catastrophic vectors of the shared masquerade do not exist here by construction - there is no multi-tenant SDK to escalate into.
Single-tenant per process#
Each instance serves exactly one app. Its SQLite stores (KV, DB, queue, cache,
fs) are scoped to one data_dir and one fixed app_id; there are no
vhost/certs/session/cross-app endpoints to reach. The instance's
environment is its own - __env reads that process's environment, not a shared
host's - so there is no cross-tenant secret to leak through it. Untrusted TSX is
compiled ahead of time at publish, never on-device, so there is no in-sandbox
compiler.
The device-only HTTP boundary#
This is the design's central property, and it is regression-tested.
A PRISM app reaches host capabilities by fetching /__bext/sdk/*. On mobile the
native bridge (camera, biometrics, geolocation, secure store, ...) lives under the
device/* namespace of that surface. But bext-lite also answers on a loopback
socket that, in untrusted hosting, is reachable by any web visitor. So the server
routes only the device/* namespace over HTTP. The data SDK - kv, db,
fs, queue, cache, secrets - is in-process only: reachable while your
loader or /api handler runs, never over the socket.
POST /__bext/sdk/device/camera/capture -> reaches the native bridge (200)
POST /__bext/sdk/kv/get -> 404 by design (never reaches the SDK)
POST /__bext/sdk/db/query | fs/read | secrets/get -> 404, each a distinct arm
A client island therefore never touches the SDK directly - it calls the app's own
/api/* routes, which touch the SDK server-side. The kv-is-404 boundary is
asserted end-to-end over a real socket (native_http_e2e): if a well-formed
kv/get ever returned 200, it would over-expose tenant data, so the test fails
the build. It also checks that secrets, db, and fs each 404 independently,
catching a regression that re-exposes a single namespace rather than the whole
SDK.
Do not try to expose /__bext/sdk/* to the browser. The data SDK is deliberately
in-process only so a visitor on an untrusted-hosting instance cannot read or write
another tenant's data. Reach it from a loader or an /api/* handler. See
On-device data.
The SSRF egress guard#
A server-side fetch to an external URL is a real blocking HTTP call, which is a
server-side request forgery surface. The guard, on by default:
- Blocks non-public targets. A request that resolves to loopback, private,
link-local (including cloud-metadata
169.254.169.254), CGNAT, unspecified, broadcast, documentation, or IPv6 ULA/link-local/v4-mapped is refused. - Pins the resolved IP (DNS-rebind / TOCTOU). The host is resolved once, the
chosen IP re-verified, and the connection pinned to that exact
SocketAddr, so a rebinding host cannot answer the guard's lookup with a public IP and the connect-time lookup with127.0.0.1. - Never auto-follows redirects. The agent runs with
redirects(0), so a302 -> http://169.254.169.254/...cannot bypass the guard; a 3xx is returned verbatim and tenant JS must re-fetch, re-entering the guard.
The app's own /api self-loopback is always allowed and exempt. A trusted
single-app deployment that must reach LAN services can opt out with
"egress": "all" in the manifest, which skips both the guard and the pinning.
OTA integrity#
Every content bundle is ed25519-signed by the publisher; the runtime is configured with the public key and refuses anything that does not verify. The apply path is fail-safe at every step, in order:
- Anti-rollback. Refuse any release not strictly newer than the installed version. A valid signature proves authenticity, not freshness - a stale mirror or attacker could otherwise replay a genuinely-signed older release to downgrade to a version with known holes. Checked before download, so a rejected downgrade costs no bandwidth.
- Size cap. Download with a byte cap equal to the manifest's declared
size, aborting if the body exceeds it - a hostile server cannot stream gigabytes into memory before the checks run. - Verify. Exact size, then sha256, then the ed25519 signature over
bext-lite-ota:v1:<version>:<sha256>- domain-separated and versioned so a signature binds version to content and cannot be replayed in another context. - runtimeAbi pin. Refuse even a correctly-signed bundle whose
manifest.jsondeclares a different runtime ABI than this build implements. - Atomic swap. Extract to a temp dir, confirm it is a real
dist-litearchive, rename into place, then flip thecurrentpointer. A failed or tampered download never becomescurrent.
The whole story - the two update tiers, the minRuntime bridge, and the App
Store 2.5.2 boundary - is in Over-the-air updates.
DoS hardening#
A single-tenant runtime on a phone (constrained memory and background CPU) or behind a public proxy needs bounded resource use, not a thread-per-connection model:
- Bounded worker pool + overflow. A fixed pool of
BEXT_LITE_SERVER_WORKERS(default 8) threads reads a zero-capacity rendezvous channel, with a bounded overflow lane so a re-entrant loopback/apifetch can never deadlock the pool. Total live handlers are capped atBEXT_LITE_SERVER_MAX_CONNS(default 64) with brief backpressure at the cap. - Request-body cap. A declared
Content-LengthaboveBEXT_LITE_MAX_BODY_BYTES(default 32 MiB) is refused413before any allocation. - Socket read timeout.
BEXT_LITE_SERVER_READ_TIMEOUT_MS(default 10 s) bounds a slowloris. - Render deadline. A QuickJS wall-clock interrupt (
BEXT_LITE_RENDER_DEADLINE_MS, default 10 s) raises an uncatchable error into a CPU-boundwhile(true){}so tenant code cannot wedge the worker forever. An awaiting render yields to the host and burns no budget while suspended. - Native-call inflight cap. A hung native op cannot be cancelled, so
BEXT_LITE_NATIVE_TIMEOUT_MS(default 30 s) stops the runtime waiting on it (504), andBEXT_LITE_NATIVE_MAX_INFLIGHT(default 16) bounds how many orphaned call threads can accumulate (503at the cap).
All are tunable - see Configuration.
OS isolation for untrusted tenants#
For untrusted multi-tenant hosting, JS-level scoping is not the whole story: a
QuickJS engine bug could in principle escape the interpreter. So the per-tenant
orchestrator wraps each instance in OS isolation, applied in a fixed order in the
child before execve (rlimit -> no_new_privs -> Landlock -> seccomp -> uid-drop):
- Distinct uids. Each tenant runs as its own stable uid (auto-allocated per
slug, or pinned via
run_as_uid), so no two tenants share a uid and a sibling cannotptraceor read a peer's/proc/*/environ. - Landlock FS confinement. The instance may write only its
data_dirand read only itsdistplus the binary; system dirs are read-only. - seccomp denylist. EPERMs
ptrace/bpf/ module-load /process_vm_*and similar. It deliberately leavessocketopen (the loopback SDK model needs it) and pairs with the FS-only Landlock. - cgroup v2 caps.
memory.max,cpu.weight, andpids.maxper instance (BEXT_LITE_MEMORY_MAX_MB/_CPU_WEIGHT/_PIDS_MAX) bound RSS, CPU share, and fork-bombs. A separate address space means a crash or runaway is contained to that tenant.
This mirrors the masquerade's per-worker isolation model, re-applied per tenant.
Enforced versus advisory#
Be precise about which controls are hard boundaries and which are defense in depth:
| Control | Status |
|---|---|
| Device-only HTTP boundary (data SDK off the socket) | Enforced - path routing, regression-tested (native_http_e2e). |
| OTA signature + sha256 + size + anti-rollback + ABI pin | Enforced - each failure rejects the bundle. |
| SSRF egress guard + DNS-rebind pin + no-redirect | Enforced by default; opt-out per instance via egress:"all". |
fs-path containment under data_dir (.. / absolute / drive stripped) |
Enforced. |
| DoS bounds (pool, body cap, read timeout, render deadline, inflight cap) | Enforced. |
| Landlock, seccomp, cgroups, uid-drop | Best-effort defense in depth. Linux-only; where the kernel lacks Landlock/cgroup-v2 a denial is non-fatal, not a hard stop. They raise the cost of a native escape, they do not by themselves make an untrusted native escape impossible. |
Publish-time policy scan (eval / new Function deny) |
Advisory lint, not a runtime boundary. QuickJS does not disable eval/Function, so dynamic eval still executes. This is acceptable only because eval'd code has the same sandboxed capabilities as the rest of the bundle - eval is not itself an escalation. Do not rely on the scan as a boundary. |
The honest summary: from inside the JS sandbox, a tenant cannot reach another tenant's data or the host - that boundary holds and is tested. A native-engine escape is the residual risk; the OS-isolation layers are what stand between such an escape and the host, and they are staged, Linux-specific, and best-effort. Ship fully-untrusted tenants only where that layer is active.
Related#
- On-device data - the in-process SDK and the client-goes-through-api pattern
- Over-the-air updates - the signing and anti-rollback detail
- Configuration - the manifest fields and env knobs referenced here
- Mobile - the same boundary on device