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.
184 lines
8.5 KiB
TypeScript
184 lines
8.5 KiB
TypeScript
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;
|
||
}
|