Keeping it safe
What the sandbox actually stops, what it does not, and the four things you have to do yourself.
melee’s position is that the compiler is not a security boundary; the kernel is. An app is Ruby compiled to a native binary, and the platform assumes that binary may do anything. What contains it is applied to the process before your code runs.
What is in place
Four of these are applied by the child itself between fork and exec, in this order, so they are in place
before the app’s own code runs and every request child inherits them. The other two are the server’s work,
before and after:
| A uid and gid per app | From --app-uid-range. This is what file modes, signals and /proc are built on. One app cannot signal another’s processes or read its files by permission. |
| User and mount namespaces | Its own view of users and of the mount table. No network namespace — apps may make outbound connections. |
| Landlock | An allow-list of paths: read on the system libraries and this app’s own release, read-write on this app’s data directory, nothing else. Paths the uid would be allowed to read are still refused. |
| seccomp | An allow-list of 85 syscalls, KillProcess on anything else. No mount, unshare, setns, ptrace, process_vm_readv, listen, bpf or keyctl. |
Around them:
| A clean environment | Built on the command by the server before the fork: cleared and rebuilt from an explicit list. |
| cgroup v2 | Applied by the server after the child exists, by writing its process id into the group: memory.max 128M, cpu.weight, pids.max 256, per app, root only. |
At the front, Host maps to an app name through a validator; response headers are rebuilt so CRLF injection
is impossible; Content-Length and Transfer-Encoding from the app are dropped and recomputed; static paths
refuse .., empty and dotfile segments; bodies are capped at --max-body; remote_addr is the socket peer,
and app routing is by Host alone, unless the peer is a configured --trusted-proxy (in which case
X-Melee-App can route instead, SEC-34). X-Melee-App and X-Melee-Edge are the server’s own headers and
never reach the app either way.
The two credential mechanisms overlap deliberately: uids cover processes and signals, Landlock covers files. Reaching another tenant’s data means defeating both.
What is not in place
Say these out loud before you put melee on the internet.
Outbound connections are unrestricted. There is no network namespace and no egress proxy. An app can
connect() anywhere, including loopback and the host’s private network — so an app that fetches a
visitor-supplied URL is an SSRF against anything else on that machine, including melee’s own control API.
Restricting this in the HTTP client is open work (SEC-17); a network namespace with an egress proxy is the
real fix and is a later milestone.
The build is not sandboxed. It runs as the server’s user, unsandboxed, on source somebody pushed, with a compiler and a C toolchain on its PATH. If you accept pushes from anyone you do not fully trust, this is the hole (SEC-04).
Apps can see the host’s processes. No PID namespace and no private /proc, so an app can enumerate what
is running — it just cannot read or signal it (SEC-10, SEC-12).
The control API is a TCP port. Loopback by default and token-protected, but a port. Moving it to a UNIX socket owned by the operator would be a real boundary now that apps have their own uids (SEC-28).
Uids are never retired. There is no delete-app route; removing an app is deleting directories by hand, and the next app to ask can be given that id along with whatever the old tenant left behind (SEC-30).
There is no TLS. Both ports are plain HTTP.
The full threat model, attacker positions and findings table live in docs/design/security.md in the
repository, and it is re-run at every milestone.
The four things you have to do
1. Put TLS in front
melee-server speaks plain HTTP on both ports. Without a terminator in front:
- session cookies are readable by anyone on the path, and the
Secureflag is only set when the request arrived over HTTPS; - the control-API token and every
melee envvalue cross the network in the clear.
The intended answer is a reverse proxy owning TLS, custom domains and compression, with melee-server behind
it keeping routing and activation. Point it at --listen, and keep --api on loopback reachable over SSH
rather than exposing it at all.
Set --trusted-proxy to the proxy’s address (127.0.0.1 when Caddy is on the same box) once it is in
place. Without it melee-server does not believe X-Forwarded-Proto, X-Forwarded-For or X-Melee-App from
anyone, so every request looks like plain http from the proxy’s own address, routed by Host alone; this
is what closes SEC-07 and SEC-34, letting any client forge Secure cookies, its own client address, or the
app it is routed to. A trusted peer’s last header line wins for all four of X-Forwarded-Proto,
X-Forwarded-For, X-Melee-App and X-Melee-Edge (for X-Forwarded-For that means its rightmost entry,
specifically) — anything earlier in any of them could be whatever an untrusted upstream hop claimed.
--trusted-proxy alone only checks the TCP address, not the process behind it. On one machine, a
sandboxed app shares 127.0.0.1 with Caddy, so without more, that app could send these same headers to
itself. Add --edge-secret-file <path> — a file holding a shared secret, same rules as --api-token-file
(mode 600, at least 32 bytes, openssl rand -hex 32 is enough) — and a --trusted-proxy peer additionally
has to send it back as X-Melee-Edge, checked in constant time; the header itself never reaches the app.
This closes SEC-33.
The two flags are meant to be set together, and the server warns at startup if only one is:
WARN --trusted-proxy is set without --edge-secret-file: TCP carries no process identity, so on one machine
every sandboxed app shares the trusted address with Caddy and can forge X-Forwarded-* and X-Melee-App for
itself the same way Caddy does (SEC-07)
WARN --edge-secret-file is set without --trusted-proxy: nothing is a trusted proxy yet, so no peer's
X-Forwarded-*, X-Melee-App or X-Melee-Edge is ever believed — pass --trusted-proxy too
On the Caddy side, set the header from its own environment:
header_up X-Melee-Edge {env.MELEE_EDGE_SECRET}
kept short here on purpose; the full Caddy recipe lives in deploy/README.md.
2. Protect the token
It is a bearer token in a file. Anything holding it can deploy any app, read any log and set any environment value on that server.
install -m 600 -o root -g root /dev/null /etc/melee/token
head -c 32 /dev/urandom | base64 | tr -d '\n=' > /etc/melee/token
On the client side, melee reads it from MELEE_TOKEN or a token_file named in melee.toml. That path
must be outside the app directory, because melee push uploads everything in it — the CLI refuses a
token file inside. There is deliberately no ~/.melee/token fallback: a single token read for any directory
would be sent to whatever host that directory’s melee.toml named, so checking out somebody else’s
repository would hand them a working deploy credential.
There is one token per server, not per app. Scoped per-app tokens are a later milestone.
Plain http:// is refused for any server other than loopback (SEC-36). 127.0.0.1, 127.x.x.x,
::1 and localhost are allowed over http://, because that is what an SSH tunnel or a same-machine dev
server look like; anything else has to be https://, since plain http:// to a real host would send the
control-API token in the clear to whatever can see the network in between.
server and url in melee.toml can each be overridden without editing the file: --server/MELEE_SERVER
and --url/MELEE_URL, a flag winning over its environment variable winning over melee.toml. An empty
override — --server "" or MELEE_SERVER= set but empty, the kind of thing a script leaves behind — counts
as not set, not as a value that wins.
Every command that talks to the control API (push, apps, env, restart, objects, logs) prints
which server it is about to use, before it reads the token or makes any request:
using control API at http://127.0.0.1:7070
or, when a flag or the environment picked it instead of melee.toml:
--server overrides melee.toml: using control API at http://127.0.0.1:7071
This is how to push to production without editing the committed melee.toml, which stays the development
configuration (pointing at 127.0.0.1:7070, reached without a tunnel):
ssh -L 7071:127.0.0.1:7070 <host>
melee --server http://127.0.0.1:7071 push
The tunnel’s local port (7071) is deliberately not 7070, the port the dev melee.toml points at:
mistyping the command and dropping --server then reaches nothing, rather than quietly deploying to
production. (The tunnel’s local end is loopback, so this still satisfies the http:// restriction above;
the encryption happens inside the SSH tunnel, not the control API’s own HTTP.)
melee open opens url in a browser and is the other place an override can hand it a value: it now checks
that url starts with http:// or https:// before invoking open/xdg-open, and otherwise prints why it
did not, rather than pass an untrusted string straight to that command.
3. Run as root, with a uid range
--app-uid-range 60000-60999
Without it, every app runs as the server’s user, which means one app can read another’s session secret and signal its processes. The server refuses to start as root without it, and warns loudly when it is not root at all. Pick a range no other user on the machine uses.
4. Decide whose code you will run
This is the real question. Today melee is safe to run your own apps on a machine you control. It is not ready to accept apps from people you do not trust, because the build step is unsandboxed and there is no provenance on what gets built. If you want to host other people, that is P-01 and SEC-04 first.
Checking it works
The repository carries a table of negative probes — things an app must not be able to do — run against real
sandboxed processes on Linux (cargo test -p melee-server -- --ignored sandbox::). They are the evidence for
the claims on this page, and they only run on Linux. A claim about sandbox behaviour that was checked on
macOS is worth nothing: there is no sandbox there at all.