Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 appFrom --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 namespacesIts own view of users and of the mount table. No network namespace — apps may make outbound connections.
LandlockAn 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.
seccompAn 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 environmentBuilt on the command by the server before the fork: cleared and rebuilt from an explicit list.
cgroup v2Applied 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 Secure flag is only set when the request arrived over HTTPS;
  • the control-API token and every melee env value 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.

Next

Logs, backups and upgrades