Files
av-club/sidecar/config.ts
T
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

184 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
/**
* Configuration, read once at boot.
*
* Every limit here exists because its absence is a denial of service — see docs/SECURITY.md,
* "Layer 5 — bounded work". They are configurable so a deployment can tighten them, not so a
* deployment can remove them: each has a floor enforced below.
*/
function num(key: string, fallback: number, min: number): number {
const raw = process.env[key];
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) {
throw new Error(`${key} must be a number, got ${JSON.stringify(raw)}`);
}
if (value < min) {
throw new Error(`${key} must be at least ${min}, got ${value}`);
}
return value;
}
/**
* The port to listen on. **0 means "any free one", and that is the default.**
*
* A plugin sidecar does not choose a number. It binds whatever is free and REPORTS it to the platform,
* which is how every other sidecar here works and why none of them can collide. av-club asserted 3020
* because it began life as a standalone service, and on 2026-08-28 that assumption cost a deploy: a
* stale copy of the pre-plugin code was still holding 3020 on fofos, so the plugin's sidecar died on
* EADDRINUSE in a restart loop.
*
* `AV_CLUB_PORT` still works and still pins the port. That is for running this outside Officer — the
* standalone mode fofos/platform was written against — where something on the other side has to know
* where to find it. Inside Officer nothing should set it.
*/
export const PORT = num("AV_CLUB_PORT", 0, 0);
/**
* Where assets live. Outside the repo by default, and deliberately not under any directory a deploy
* would replace — losing every stored asset because the working tree was refreshed is the kind of
* failure that is only noticed later, by which point the originals are gone.
*/
export const ASSETS_DIR = resolve(process.env.AV_CLUB_ASSETS_DIR ?? `${process.env.HOME}/.local/share/av-club/assets`);
/** Multipart bodies are buffered whole. Without a cap, one request is an out-of-memory. */
/**
* The only part of the filesystem this service will read from or write to when a caller names a path.
*
* Defaults to the owner's home, which is where every consumer's files already are — the file browser
* runs there, and so does the commander watching an upload folder. Narrow it with the env var on a
* machine where av-club should only see one project.
*
* `/` is refused outright rather than treated as "no limit": a root that permits everything is a
* configuration mistake that would look like it was working.
*/
export const WORK_ROOT = (() => {
const root = resolve(process.env.AV_CLUB_WORK_ROOT ?? process.env.HOME ?? "");
if (!root || root === "/") {
throw new Error("AV_CLUB_WORK_ROOT (or HOME) must be a real directory, and may not be /");
}
return root;
})();
export const MAX_UPLOAD_BYTES = num("AV_CLUB_MAX_UPLOAD_BYTES", 512 * 1024 * 1024, 1024);
/**
* How much RAM ImageMagick may hold while decoding, before it spills to a temporary file.
*
* Deliberately modest. This is the one operation here that can allocate against the size of the
* PICTURE rather than against anything this service chose, so it is the one that can take a small
* machine down — and taking a machine down is a worse outcome than being slow, every time, because
* the other things on it did not ask to be part of an image conversion.
*
* 256 MB comfortably holds the working set of a phone photograph. Past it nothing fails: ImageMagick
* memory-maps and then falls back to disk, so a genuinely enormous image is slower rather than fatal.
*
* Raise it on a machine with headroom. See sharp/decodable.ts for how it is applied.
*/
export const CONVERT_MEMORY_MB = num("AV_CLUB_CONVERT_MEMORY_MB", 256, 32);
/**
* How long one encode may run before it is killed.
*
* ── What it is for ──
*
* A crafted file can make an encoder run effectively forever. This is the ceiling that stops one
* request holding a worker for the life of the process. It is a guard against FOREVER, not a budget
* for how long work should take.
*
* ── Why it is not 120 seconds any more ──
*
* It was, and that is a budget rather than a ceiling — it fits a short clip and nothing else. Video
* encoding is measured against the length of the video: a 30-second hero at two renditions took about
* 40 seconds, so a two-minute upload could not finish and failed with "Encoding took longer than 120s
* and was stopped." The file was fine, the encoder was working, and it was killed for being ordinary.
*
* Thirty minutes is still a ceiling. Nothing legitimate this service is asked for approaches it, and a
* runaway is still stopped rather than left running — which is the whole property being defended.
*
* ── Tightening it ──
*
* `AV_CLUB_JOB_TIMEOUT_MS`, per machine. A host that only ever converts photographs can safely set this
* low; one that takes long video should leave it alone. The default has to fit the worst legitimate
* case, because the cost of guessing low is a rejected upload and the cost of guessing high is a
* runaway that runs a while longer before being killed anyway.
*/
export const JOB_TIMEOUT_MS = num("AV_CLUB_JOB_TIMEOUT_MS", 1_800_000, 1_000);
/**
* Decompression bombs: a 1 KB PNG can expand to tens of gigabytes. This is sharp's own
* `limitInputPixels`, defaulted to its documented default (0x3FFF × 0x3FFF) rather than to `false`.
*/
export const MAX_INPUT_PIXELS = num("AV_CLUB_MAX_INPUT_PIXELS", 0x3fff * 0x3fff, 1);
/** `resize(50000, 50000)` is an allocation request, not an image. */
export const MAX_OUTPUT_DIMENSION = num("AV_CLUB_MAX_OUTPUT_DIMENSION", 16_383, 1);
/** ffmpeg will take every core it is given. Two callers should not be able to take the box down. */
export const MAX_CONCURRENT_JOBS = num("AV_CLUB_MAX_CONCURRENT_JOBS", 2, 1);
/**
* The bearer token every request must carry.
*
* A FUNCTION, not a constant evaluated at import. The limits above are plain data that anything may
* 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.
*
* `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 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 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;
}