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.
202 lines
11 KiB
Markdown
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**
|