adds a path mode to /v1/ffmpeg:
{ input: "as_…" } → stored as a new asset, 201
{ inputPath: "/…", outputPath: "/…" } → written where you said, 200 { path, bytes }
THIS REVISES THE INVARIANT IN docs/SECURITY.md, deliberately, and that document now
says so at the top rather than being quietly contradicted by the code.
it used to read "the caller never supplies a path, for input or output". that was
right for an upload service and av-club is not one: both of its real consumers —
a right-click on ~/Movies/thing.mkv, and a commander watching an upload folder —
already have the file, and obeying the rule meant copying gigabytes in and back out
to convert something that never moved. a rule that expensive gets worked around,
and the workaround is where the hole would have been.
so it is narrower now and says what it was always for. argv never a shell,
-protocol_whitelist file, filters allowlisted, work bounded — all unchanged. what
is gone is only the claim that a path never appears, replaced by one small module
every path goes through:
absolute (a relative path resolves against a cwd the caller cannot see)
realpath FIRST, then check — checking the path as written and opening the
resolved one is the classic way this fails
inside 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 resolved, and must already exist
errors never say which of "outside" or "missing" it was, or this becomes an
oracle for what exists elsewhere on the disk
VERIFIED against the running service, not reasoned about: `..` traversal, an
absolute path outside the root, a write outside the root, a relative path, a
symlink pointing into /etc, and a write THROUGH a symlinked directory — all
refused, nothing written outside the root.
the output is moved into place at the END. ffmpeg encodes into a temp dir, so a job
killed by the timeout or a restart leaves the caller's directory as it was. pointing
ffmpeg at the destination would leave a truncated file where a good one might have
been — and a task that then deleted its source would have destroyed something.
rename first, copy on EXDEV, which is the normal case rather than the error one
since tmp is often tmpfs.
docs/API.md said this endpoint was "designed, not yet built" while it was routed and
working. that is fixed too: it was read as a gap and nearly rebuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.7 KiB
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-08-29
The caller may name a path.
sidecar/paths.tsis the ONLY thing that decides whether it is one this service will touch, and it confines every one of them toWORK_ROOT.
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
realpathFIRST, so a symlink inside the root pointing at/etc/shadowis resolved and then rejected — checking the path as written and opening the resolved one is the classic way this fails- inside the root as
rootorroot + sep, never a barestartsWith, which accepts/home/user-backupfor a root of/home/user - for an output, the parent is resolved and checked, 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
Verified 2026-08-29 against the running service: .. traversal, an absolute path outside the root, a
write outside the root, a relative path, a symlink pointing into /etc, and a write through a
symlinked directory are all refused; nothing was written outside the root.
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/nsjailsandbox 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-ifexpression - 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