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.
This commit is contained in:
2026-09-09 23:34:39 +01:00
parent 62972c1146
commit a467000b3d
3 changed files with 138 additions and 28 deletions
+27 -8
View File
@@ -6,10 +6,11 @@ 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 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 it confines every one of them to `WORK_ROOT`.**
> 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.
@@ -44,18 +45,36 @@ What is gone is only the claim that a path never appears. What replaced it is on
to read in a minute, that every path goes through:
- **absolute**, because a relative path resolves against a cwd the caller cannot see
- **`realpath` FIRST**, so a symlink inside the root pointing at `/etc/shadow` is resolved and then
rejected — checking the path as written and opening the resolved one is the classic way this fails
- **inside the root as `root` or `root + sep`**, never a bare `startsWith`, which accepts
- **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 resolved and checked, and it must already exist — creating
- 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, a relative path, a symlink pointing into `/etc`, and a write through a
symlinked directory are all refused; nothing was written outside the root.
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