Security model¶
What blastit claims, and what it does not. The argument is in
ARCHITECTURE.md §7.
Threat model¶
In scope: compromise at request time — RCE in a view, SQL injection, a deserialisation bug, a vulnerable dependency reachable from a route. The question is what the attacker is holding when that happens.
Out of scope, deliberately:
- code the app runs at import time — imports run before the sandboxes exist, with the real credentials still in the environment;
- other secrets in the environment — an API key the app must hold cannot be
swapped, and every route can read it.
BLASTIT_ENV_STRIPhides what you name; a proxy is the general answer, and onlypostgresqlhas one; - TLS, HTTP/2, WebSockets and
Upgrade— a load balancer terminates TLS, and blastit answers501; - per-uid egress filtering — needs
CAP_NET_ADMIN; the available lever is a seccomp filter onsocket(). - constraining parameter values — a grant says which statement, not which row;
- an attacker who already has root in the container, and the app's own behaviour.
What a compromise gets¶
| If this is compromised… | …the attacker gets | …and cannot |
|---|---|---|
| a view (request-time RCE) | that route's allowlisted statements; its scratch directory; every file its uid can open — the code tree, the virtualenv, /tmp; all the CPU and memory the container has; egress only with egress = true |
run any other statement; read the real database credentials; ptrace another process; connect to another route's socket or channel; hold more threads than its RLIMIT_NPROC; take another route's worker slot |
| http | the raw bytes of in-flight HTTP traffic | reach the database; run Python; spawn processes |
| the scheduler | it can send any request to any view | do anything a view could not already be made to do by an HTTP request |
| dbproxy | the real database credentials | — it runs no app code, is not dumpable, and is kept small |
| init | everything | — it runs only blastit code after stage 1's imports |
Two lines in that row are worth reading twice: a view has no CPU or memory limit
of its own, and it can read any file its uid can open. Cap the container for
that; BLASTIT_MAX_WORKERS bounds the threads.
The sandbox comes from uid privileges, not code isolation: all the app's code is mapped into every view, so a view can run another route's code — only with its own allowlist. That is why a misrouted request is safe: it lands in a process whose allowlist does not fit, and its queries are denied.
What the sandboxed kernel does not do¶
Every process has its own uid, gid == uid, no supplementary groups: different
uids cannot signal, ptrace or read each other's
/proc/<pid>/{mem,io,cwd,root}. Two things differ from bare Linux:
- another uid can read
/proc/<pid>/{environ,cmdline,maps,auxv,stat,status}and list/proc/<pid>/fd. So nothing secret is ever inenvironorargv, and a view seeing another's file descriptors and memory layout is accepted rather than fought; SO_PEERCREDreturns the listener's own credentials. That is why identity is the listener plusSCM_CREDENTIALS: socket file permissions are enforced atconnect(), and forging the credentials fails withEPERM.
The mechanisms¶
- The credential barrier. No process ever holds a real secret in its
environorargv; the one that does isexecve'd with an empty environment and receives them over an inherited pipe. Aforkalone would not do it: a forked child keeps showing its parent's initial environment block. - Capabilities. Every child clears every capability set it holds and sets
PR_SET_NO_NEW_PRIVS, and the drop is checked, not assumed. That flag is also what makes the capable copy harmless to a child: the kernel refuses anexecveof a file whose capabilities cannot be honoured. - seccomp. Each child installs a BPF refusal list after dropping privileges:
the default is allow, and what it refuses it refuses with
EPERM. Every role refusesptrace,bpf,keyctl,perf_event_open,io_uring, the module-loading calls,mountand the new mount API,setnsandunshare— the calls that read or steer another process, load kernel code or move between namespaces. Views may useAF_UNIXonly, plusAF_INET/AF_INET6withegress = true; http and the scheduler may not callsocket()at all. - Filesystem and limits. The code tree is read-only to views; each view gets a
private
0700scratch directory,umask 077and that directory asHOMEandTMPDIR;RLIMIT_NPROC(counting threads) is a hard backstop under the scheduler's budget, withRLIMIT_NOFILEandRLIMIT_CORE=0;PR_SET_PDEATHSIG(SIGKILL)means no process outlives init.
The client address¶
REMOTE_ADDR and wsgi.url_scheme come from one resolver, which believes a
header only from a peer in BLASTIT_TRUSTED_PROXIES. Both presets use *: every
peer is trusted, and the rightmost chain entry is the client — the one the hop
closest to blastit added. The full rule is in
client IP and trusted proxies.
Check it yourself¶
The end-to-end tests drive a Django app over HTTP, run the same raw SQL from routes with different grants, and assert on blastit's log: the denials name the right route, and the password appears nowhere in it.