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:
+27
-8
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user