Files
pastilhas a467000b3d a symlink the owner put inside the root may lead out of it
Two failures on 2026-09-09, one cause each, both found by a 212-track album that would not convert.

── Symlinks ──

paths.ts ran realpath FIRST and required the resolved path to be inside WORK_ROOT, so a link in the
owner's home pointing at another disk was refused. On a machine whose home is BUILT from such links —
~/Music, ~/Movies, ~/Torrents each pointing at a mount — that leaves av-club able to see almost
nothing. It refused every track of a folder the owner had open in the file browser at the time.

That is the tell. The file browser decides what the owner may touch and it launches these jobs; its
resolveUserPath checks the path as written and never calls realpath. Two confinement rules over the
same files means the platform offers a right-click action its own sidecar cannot perform.

What the strict rule was worth is smaller than it looks: this process runs AS the owner, so it reads
nothing the owner cannot already read, and a link out of home reaches only more of the owner's disks.
realpath-first was never the privilege boundary — the OS is.

`..` is now refused rather than normalised, which is what realpath-first actually bought. Normalising
is not what the kernel does: for /home/me/Music/.. the string says /home/me while the kernel follows
Music to another filesystem first. Refusing removes the divergence instead of picking a side.

The narrower claim that remains, and is the one to defend: a caller cannot NAME a path outside the
root; they can only follow a link the owner put inside it.

── The token ──

requireToken() threw when AV_CLUB_TOKEN was unset. That is right for a service a person starts with a
shell and wrong for a sidecar the platform starts: not a safe default, a crash loop nobody sees. It
was exactly that — 15 restarts, every proxied request 503, and an error that named the files rather
than the missing variable.

Generating one loses nothing: authorised() already accepts the platform's X-Officer-User header with
no token at all, so no real caller carried this secret. It guards the direct loopback door, and 32
random bytes guard it better than a value copied into a shell profile. Kept 0600 beside the assets so
replacing the working tree does not change the service's identity.

AV_CLUB_TOKEN still wins when set. There is no unauthenticated mode; this only decides where the
token comes from.
2026-09-09 23:34:39 +01:00

202 lines
11 KiB
Markdown

# Security model
av-club exposes ffmpeg, sharp and exiftool over HTTP. Done naively that is a remote shell. This
document is the set of invariants that make it not one, and the reasoning for each — so that a future
change can be checked against the reasoning rather than against a vibe.
**Read this before adding an endpoint, an option, or a filter.**
## The one invariant, as revised on 2026-09-09
> **The caller may name a path. `sidecar/paths.ts` is the ONLY thing that decides whether it is one
> this service will touch, and every path a caller NAMES is inside `WORK_ROOT`. A symlink the owner
> put inside the root may lead out of it.**
Read the section below for what it replaced and why, because the change is not a relaxation of the
threat model — it is a correction to a rule that had stopped describing what av-club is for.
### What it used to say, and why it changed
> **The caller never supplies a path, a URL, or a protocol. Not for input, not for output, not
> inside a filter argument, not anywhere.**
Every dangerous thing these tools can do is reached through a path or a URL. Remove the caller's
ability to name one and the entire class disappears, which is what makes it safe to expose almost
every *other* option these tools have.
Concretely: input is an **opaque asset id** issued by av-club for a file av-club received. Output is
a path av-club chooses. Both are resolved server-side into paths under a job directory. Anything that
looks like a path in a caller-supplied value is rejected, not sanitised.
That was the right invariant for an upload service. av-club is not one. Both of its real consumers
already have the file on disk — a right-click in the file browser on `~/Movies/thing.mkv`, and a
commander watching a folder for uploads — and obeying the old rule meant copying gigabytes in and
gigabytes back out to convert a file that never moved. A rule that expensive gets worked around, and
the workaround is where the actual hole would have been.
So it is now narrower and says what it was always FOR. Nothing else moved:
- argv arrays, never a shell — unchanged
- `-protocol_whitelist file` — unchanged, and now doing more work than before
- filters allowlisted by name, file-bearing parameters stripped — unchanged
- bounded work, timeouts, concurrency — unchanged
What is gone is only the claim that a path never appears. What replaced it is one module, small enough
to read in a minute, that every path goes through:
- **absolute**, because a relative path resolves against a cwd the caller cannot see
- **no `..` segment**, refused rather than normalised. Normalising is not what the kernel does: for
`/home/me/Music/..` the string says `/home/me`, while the kernel follows `Music` to another disk
first and says something else entirely. Refusing removes the divergence rather than picking a side
- **inside the root as written**, as `root` or `root + sep`, never a bare `startsWith`, which accepts
`/home/user-backup` for a root of `/home/user`
- for an output, the **parent** is checked the same way, and it must already exist — creating
directories toward an output is a side effect nobody asked for
- errors never distinguish "outside the root" from "does not exist", which would make this an oracle
for what exists elsewhere on the disk
**Symlinks leave the root, deliberately, changed 2026-09-09.** `realpath` used to run FIRST and the
resolved path had to be inside the root, so a link in the owner's home pointing at another disk was
refused. On a machine whose home is BUILT from such links — `~/Music`, `~/Movies`, `~/Torrents` each
pointing at a mount — that rule left av-club able to see almost nothing. It refused all 212 tracks of
an album the owner had open in the file browser at the time.
The file browser is the component that decides what the owner may touch, and it launches these jobs;
its `resolveUserPath` checks the path as written and never calls `realpath`. Two different rules over
the same files means the platform offers a right-click action its own sidecar cannot perform.
What that strictness was worth is smaller than it looks: this process runs AS the owner, so it reads
nothing the owner cannot already read, and a link out of home reaches only more of the owner's disks.
`realpath`-first was never the privilege boundary — the OS is. The claim that remains true, and is the
one to defend: **a caller cannot NAME a path outside the root; they can only follow a link the owner
put inside it.**
Verified 2026-08-29 against the running service: `..` traversal, an absolute path outside the root, a
write outside the root, and a relative path are all refused; nothing was written outside the root. The
two symlink cases in that list — a link into `/etc`, and a write through a symlinked directory — are
now permitted by design, per the paragraph above.
### What that invariant is defending against
These all work by default, which is why "just pass the flags through" is not an option:
| Attack | How |
|---|---|
| Read any file the process can | `-i /etc/passwd`, then download the "converted" output |
| Read a file the input filter never sees | `-f lavfi -i "movie=/home/user/.ssh/id_ed25519"` |
| Read arbitrary paths via a demuxer | `-f concat -i list.txt` with a caller-controlled list |
| SSRF into the private network | ffmpeg speaks `http:` — cloud metadata endpoints, internal services |
| Write anywhere the process can | the output path is just an argument: `~/.ssh/authorized_keys` |
| Load arbitrary native code | `frei0r`, `ladspa`, `lv2` filters load plugins by name from disk |
Note the second row especially. A guard that only inspected `-i` would pass it, because the read
happens inside the filter graph. **This is why the rule is about values, not about which flag they
arrived in.**
## Layer 1 — no shell, ever
Processes are spawned with an **argv array**, never a command string. There is no shell to quote for,
so shell metacharacters are inert data rather than a parsing problem.
Anything that builds a command by string concatenation is a bug regardless of how well it escapes.
## Layer 2 — the caller names nothing
| | |
|---|---|
| **Input** | An asset id (opaque, server-issued) or a multipart upload. Never a path or URL. |
| **Output** | av-club picks the filename and directory. The caller picks the *format*, not where it lands. |
| **Working dir** | One temp directory per job, removed when the job ends. |
Asset ids are validated against a strict pattern before they are used to build a path, and the
resolved path is checked to still be inside the store afterwards. Two checks, because the first is
about intent and the second is about what actually happened.
## Layer 3 — ffmpeg runs with protocols disabled
```
-protocol_whitelist file (nothing else — no http, no concat, no subfile, no data)
-f <explicit> inputs are demuxed as what we probed them to be, not auto-detected
-nostdin ffmpeg never waits on stdin
```
`-protocol_whitelist file` alone kills SSRF and the `concat:`/`subfile:` tricks outright. It is not
a mitigation for a path leak — Layer 2 is — it is the second lock.
## Layer 4 — filters are allowlisted by name, and their arguments are checked
ffmpeg's filter graph is a language. Most of it is arithmetic on pixels and samples and is safe.
A small, specific part of it touches the filesystem or loads code.
**Never allowed** — these read files or load native code, by design:
`movie`, `amovie`, `subtitles`, `ass`, `frei0r`, `frei0r_src`, `ladspa`, `lv2`, `sendcmd`,
`asendcmd`, `vidstabtransform`, `libvmaf`, `openclsrc`
**Allowed, but with file-bearing parameters stripped**: `drawtext` is genuinely useful and carries
`textfile=` and `fontfile=`. The filter is permitted; those two parameters are rejected. Text comes
as a literal, fonts come from a fixed server-side set referenced by name.
The list is a **default-deny allowlist**, not a blocklist. A filter nobody has classified is
refused. A blocklist would silently admit whatever the next ffmpeg release adds.
## Layer 5 — bounded work
Unbounded is a denial of service even when it is not a breach.
| Bound | Why |
|---|---|
| Upload size cap | Multipart bodies are buffered; an unbounded one is an OOM |
| Wall-clock timeout per job, process killed on expiry | A crafted file can make an encoder run effectively forever |
| Output dimension cap | `resize(50000, 50000)` is an allocation, not an image |
| `limitInputPixels` on sharp | Decompression bombs: a 1 KB PNG that expands to 40 GB |
| Concurrency cap | ffmpeg will use every core it is given; two callers should not be able to take the box down |
| Job directory removed on completion **and** on failure | Otherwise disk fills with the failures |
## Layer 6 — the untrusted-parser problem, stated honestly
Everything above constrains what a caller may *ask for*. None of it helps with the fact that
**ffmpeg and libvips are large C/C++ codebases parsing hostile binary input**, which is a
memory-safety attack surface with a long CVE history — ImageTragick (CVE-2016-3714) and exiftool's
DjVu RCE (CVE-2021-22204) are the famous ones, and they are not the last.
The honest mitigation is process isolation, and it is a *deployment* property rather than a code one:
- run the service as a dedicated unprivileged user that owns nothing else
- no network egress from the process (with protocols disabled it has no reason to make one)
- read-only filesystem apart from the job directory
- ideally a container or `bubblewrap`/`nsjail` sandbox per job
**Status: not implemented yet.** It is written down here because it is the difference between "a
bug in ffmpeg is a crash" and "a bug in ffmpeg is our whole machine", and because the thing most
likely to happen otherwise is that it is never considered at all.
## exiftool is treated differently
exiftool is a Perl program with a plugin architecture and a history of code execution from
**parsing** — not from its arguments. It is therefore used narrowly:
- **reading only**, with a fixed argument list, never caller-supplied options
- never `-@` (argfile), never `-config`, never a user-supplied `-if` expression
- stripping metadata is done by **sharp's re-encode**, not by exiftool
Sharp drops EXIF by default on re-encode, which is verified on every `scripts/verify.sh` run. That
is a smaller, better-understood surface than asking exiftool to rewrite a file in place.
## Authentication
The service is not public and has no anonymous surface. Every endpoint requires a bearer token
issued out of band. This is deliberately dull — it is an internal service between two things we
operate, not a product.
**Even so, it must not be reachable from the internet.** Auth is the second lock; the first is not
listening on a public interface.
## Reviewing a change against this document
- Does the caller name a path, a URL, or a protocol anywhere in the new surface? → **no**
- Is a new ffmpeg filter classified in the allowlist, with a reason? → **required**
- Is the new option's value constrained by type and range, or is it a free string? → **constrain it**
- Can it produce unbounded work — time, memory, pixels, files? → **bound it**
- Is a command being built as a string? → **it must not be**