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

11 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-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