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:
+51
-10
@@ -1,4 +1,6 @@
|
||||
import { resolve } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Configuration, read once at boot.
|
||||
@@ -127,16 +129,55 @@ export const MAX_CONCURRENT_JOBS = num("AV_CLUB_MAX_CONCURRENT_JOBS", 2, 1);
|
||||
* read — the doc generator imports the sharp vocabulary, which imports MAX_OUTPUT_DIMENSION from
|
||||
* here — and a module-scope throw meant that generating documentation required a production secret.
|
||||
*
|
||||
* The check itself still happens at boot: server.ts calls this before it listens, so a missing token
|
||||
* is a refusal to start rather than a service that quietly accepts everyone. There is no
|
||||
* "unauthenticated by default" mode; this process runs decoders over untrusted bytes.
|
||||
* `AV_CLUB_TOKEN` still wins when it is set — that is how the standalone deployment pins a value
|
||||
* something else already knows. When it is not set, one is generated and kept (see below) rather than
|
||||
* refused. There is still no "unauthenticated by default" mode: the door always wants a token, this
|
||||
* only decides where that token comes from.
|
||||
*/
|
||||
export function requireToken(): string {
|
||||
const value = process.env.AV_CLUB_TOKEN;
|
||||
if (!value || value.length < 16) {
|
||||
throw new Error(
|
||||
"AV_CLUB_TOKEN must be set and at least 16 characters. Generate one with: openssl rand -hex 32",
|
||||
);
|
||||
const fromEnv = process.env.AV_CLUB_TOKEN;
|
||||
if (fromEnv) {
|
||||
if (fromEnv.length < 16) {
|
||||
throw new Error("AV_CLUB_TOKEN, when set, must be at least 16 characters.");
|
||||
}
|
||||
return fromEnv;
|
||||
}
|
||||
return value;
|
||||
|
||||
return tokenFromStateDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* The token, generated and kept on disk when nothing supplied one.
|
||||
*
|
||||
* This used to throw, and that was wrong for a plugin. A sidecar is started by the platform, not by a
|
||||
* person with a shell — so "refuse to boot until an operator exports a variable" is not a safe default,
|
||||
* it is a crash loop nobody sees. On 2026-09-09 it was exactly that: av-club restarted 15 times, and
|
||||
* every request the platform proxied to it came back 503. A whole 212-file conversion failed with an
|
||||
* error that named the files, not the missing variable.
|
||||
*
|
||||
* Generating one loses nothing. `authorised()` already accepts the platform's `X-Officer-User` header
|
||||
* without a token at all, so no real caller was ever carrying this secret — it only guards the direct
|
||||
* loopback door, and a random 32 bytes guards that better than a value copied into a shell profile.
|
||||
*
|
||||
* Written 0600 next to the assets, for the same reason they live there: a deploy that replaces the
|
||||
* working tree must not change the service's identity.
|
||||
*/
|
||||
function tokenFromStateDir(): string {
|
||||
const file = resolve(ASSETS_DIR, "..", "token");
|
||||
|
||||
// Missing is the normal first-boot case, not an error. Anything else — a directory in the way, a
|
||||
// permission problem — is also survivable: fall through and mint a fresh one rather than refuse to start.
|
||||
const existing = (() => {
|
||||
try {
|
||||
return readFileSync(file, "utf8").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
if (existing.length >= 16) return existing;
|
||||
|
||||
const generated = randomBytes(32).toString("hex");
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, `${generated}\n`, { mode: 0o600 });
|
||||
return generated;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user