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.

Warning

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 with 127.0.0.1.
  • Never auto-follows redirects. The agent runs with redirects(0), so a 302 -> 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:

  1. 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.
  2. 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.
  3. 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.
  4. runtimeAbi pin. Refuse even a correctly-signed bundle whose manifest.json declares a different runtime ABI than this build implements.
  5. Atomic swap. Extract to a temp dir, confirm it is a real dist-lite archive, rename into place, then flip the current pointer. A failed or tampered download never becomes current.

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 /api fetch can never deadlock the pool. Total live handlers are capped at BEXT_LITE_SERVER_MAX_CONNS (default 64) with brief backpressure at the cap.
  • Request-body cap. A declared Content-Length above BEXT_LITE_MAX_BODY_BYTES (default 32 MiB) is refused 413 before 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-bound while(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), and BEXT_LITE_NATIVE_MAX_INFLIGHT (default 16) bounds how many orphaned call threads can accumulate (503 at 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 cannot ptrace or read a peer's /proc/*/environ.
  • Landlock FS confinement. The instance may write only its data_dir and read only its dist plus the binary; system dirs are read-only.
  • seccomp denylist. EPERMs ptrace / bpf / module-load / process_vm_* and similar. It deliberately leaves socket open (the loopback SDK model needs it) and pairs with the FS-only Landlock.
  • cgroup v2 caps. memory.max, cpu.weight, and pids.max per 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.