From 2ad8ae73d5bec02f6339791af90ec5665bb97f0a Mon Sep 17 00:00:00 2001 From: khannurien Date: Thu, 24 Sep 2026 20:07:33 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20API=20stops=20trusting=20what=20it'?= =?UTF-8?q?s=20handed=20=E2=80=94=20a=20review=20of=20api/=20turned=20up?= =?UTF-8?q?=20a=20server=20anyone=20could=20crash,=20uploads=20that=20ran?= =?UTF-8?q?=20script=20on=20the=20app's=20origin,=20and=20a=20server=20tha?= =?UTF-8?q?t=20would=20fetch=20its=20own=20network=20on=20request,=20and?= =?UTF-8?q?=20this=20closes=20them=20along=20with=20the=20smaller=20holes?= =?UTF-8?q?=20around=20them.=20A=20WebSocket=20message=20that=20wasn't=20a?= =?UTF-8?q?n=20object=20(a=20bare=20`null`=20will=20do)=20threw=20inside?= =?UTF-8?q?=20the=20listener,=20which=20Deno=20treats=20as=20uncaught=20an?= =?UTF-8?q?d=20exits=20on;=20messages=20are=20now=20shape-checked=20and=20?= =?UTF-8?q?the=20handler=20can't=20throw=20out.=20Uploaded=20files=20kept?= =?UTF-8?q?=20the=20MIME=20type=20the=20uploader's=20browser=20claimed,=20?= =?UTF-8?q?so=20an=20.html=20or=20.svg=20dump=20opened=20from=20/api/files?= =?UTF-8?q?=20ran=20with=20the=20session=20token=20in=20reach;=20only=20im?= =?UTF-8?q?ages,=20audio,=20video,=20PDF=20and=20plain=20text=20are=20serv?= =?UTF-8?q?ed=20inline=20now,=20the=20rest=20download,=20and=20every=20fil?= =?UTF-8?q?e=20carries=20nosniff=20and=20a=20sandboxing=20CSP=20(PDFs=20as?= =?UTF-8?q?ide,=20which=20Chrome=20won't=20render=20sandboxed).=20The=20im?= =?UTF-8?q?age=20proxy=20passed=20image/svg+xml=20straight=20through=20the?= =?UTF-8?q?=20same=20way=20and=20now=20takes=20raster=20formats=20only.=20?= =?UTF-8?q?Outbound=20fetches=20for=20previews,=20the=20proxy=20and=20Band?= =?UTF-8?q?camp=20checked=20the=20hostname=20against=20a=20partial=20list?= =?UTF-8?q?=20=E2=80=94=20no=20169.254.169.254,=20no=200.0.0.0,=20an=20IPv?= =?UTF-8?q?6=20check=20that=20never=20matched=20a=20bracketed=20hostname,?= =?UTF-8?q?=20nothing=20for=20names=20that=20resolve=20inward,=20and=20red?= =?UTF-8?q?irects=20followed=20blindly;=20every=20hop=20now=20resolves=20a?= =?UTF-8?q?nd=20is=20refused=20if=20any=20address=20is=20private=20or=20re?= =?UTF-8?q?served,=20redirects=20are=20walked=20by=20hand,=20the=20curl=20?= =?UTF-8?q?fallback=20is=20pinned=20to=20the=20checked=20IP,=20and=20/api/?= =?UTF-8?q?preview=20needs=20a=20login.=20PATCH=20/users/me=20accepted=20a?= =?UTF-8?q?=20new=20password=20without=20the=20current=20one=20and=20now?= =?UTF-8?q?=20takes=20only=20the=20description=20and=20email;=20a=20wrong?= =?UTF-8?q?=20password=20at=20login=20answered=20500=20because=20the=20han?= =?UTF-8?q?dler=20caught=20its=20own=20401,=20and=20now=20says=20401.=20Ch?= =?UTF-8?q?anging=20or=20resetting=20a=20password=20now=20ends=20every=20o?= =?UTF-8?q?lder=20session=20through=20users.tokens=5Fvalid=5Fafter=20(migr?= =?UTF-8?q?ation=200015)=20and=20an=20`iat`=20on=20every=20token,=20with?= =?UTF-8?q?=20change-password=20handing=20back=20fresh=20tokens=20the=20mo?= =?UTF-8?q?dal=20keeps,=20and=20all=20token=20checks=20go=20through=20one?= =?UTF-8?q?=20authenticate()=20that=20also=20refuses=20deleted=20users.=20?= =?UTF-8?q?Invites=20and=20reset=20links=20are=20claimed=20atomically=20be?= =?UTF-8?q?fore=20the=20async=20hash=20rather=20than=20marked=20after,=20s?= =?UTF-8?q?o=20one=20invite=20no=20longer=20opens=20several=20accounts,=20?= =?UTF-8?q?and=20an=20email=20address=20belongs=20to=20one=20account.=20Fi?= =?UTF-8?q?le=20and=20thumbnail=20URLs=20carry=20a=20media-only=20token=20?= =?UTF-8?q?rather=20than=20the=20session=20token,=20so=20a=20URL=20in=20a?= =?UTF-8?q?=20log=20exposes=20files=20and=20not=20the=20account;=20older?= =?UTF-8?q?=20sessions=20fetch=20one=20on=20load.=20Private=20things=20sto?= =?UTF-8?q?p=20leaking=20into=20broadcasts:=20private=20playlist=20updates?= =?UTF-8?q?=20go=20to=20their=20owner,=20votes=20and=20likes=20on=20someon?= =?UTF-8?q?e=20else's=20private=20dump=20404=20and=20their=20counts=20go?= =?UTF-8?q?=20to=20the=20owner=20alone,=20and=20a=20private=20dump=20can't?= =?UTF-8?q?=20be=20slipped=20into=20a=20playlist.=20Smaller:=20selfProvide?= =?UTF-8?q?r=20only=20claims=20this=20site's=20own=20host,=20so=20github.c?= =?UTF-8?q?om/users/bob=20stops=20rendering=20our=20bob;=20the=20chat=20re?= =?UTF-8?q?ad=20marker=20can't=20be=20parked=20in=20the=20future=20on=20an?= =?UTF-8?q?=20empty=20chat;=20dump=20mutations=20use=20the=20resolved=20id?= =?UTF-8?q?=20so=20a=20slug=20works;=20multipart=20dumps=20get=20the=20tit?= =?UTF-8?q?le=20and=20comment=20limits;=20logins=20and=20reset=20requests?= =?UTF-8?q?=20are=20rate-limited=20through=20a=20shared=20token=20bucket;?= =?UTF-8?q?=20the=20password=20compare=20is=20constant-time;=20ffmpeg=20re?= =?UTF-8?q?ads=20only=20local=20files=20in=20known=20video=20containers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/db/migrate.ts | 2 + api/db/migrations/0015_token_revocation.ts | 17 ++ api/db/schema.sql | 3 +- api/lib/auth.ts | 75 ++++++- api/lib/jwt.ts | 41 +++- api/lib/public-address.ts | 137 +++++++++++++ api/lib/rate-limit.ts | 39 ++++ api/lib/self-url.ts | 23 +++ api/lib/upload.ts | 7 +- api/middleware/auth.ts | 19 +- api/model/interfaces.ts | 3 + api/routes/dumps.ts | 12 +- api/routes/files.ts | 44 ++++- api/routes/preview.ts | 31 ++- api/routes/thumbnails.ts | 32 +-- api/routes/users.ts | 155 +++++++++------ api/routes/ws.ts | 215 ++++++++++++--------- api/services/chat-service.ts | 5 +- api/services/comment-like-service.ts | 40 +++- api/services/dump-service.ts | 22 +++ api/services/invite-service.ts | 24 ++- api/services/password-reset-service.ts | 25 ++- api/services/playlist-service.ts | 33 ++-- api/services/providers/self.ts | 6 +- api/services/rich-content-service.ts | 116 +++++++---- api/services/user-service.ts | 70 +++++-- api/services/vote-service.ts | 35 +++- api/services/ws-service.ts | 74 +++---- src/components/ChangePasswordModal.tsx | 11 +- src/components/DumpCard.tsx | 4 +- src/components/DumpCreateModal.tsx | 2 +- src/components/FilePreview.tsx | 4 +- src/components/JournalCard.tsx | 8 +- src/contexts/AuthProvider.tsx | 27 +++ src/hooks/useAuth.ts | 9 +- src/model.ts | 2 + src/pages/Dump.tsx | 4 +- src/pages/DumpEdit.tsx | 8 +- 38 files changed, 1032 insertions(+), 352 deletions(-) create mode 100644 api/db/migrations/0015_token_revocation.ts create mode 100644 api/lib/public-address.ts create mode 100644 api/lib/rate-limit.ts create mode 100644 api/lib/self-url.ts diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 5606523d..4f8d7e9f 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -13,6 +13,7 @@ import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favic import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts"; import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts"; import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts"; +import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.ts"; interface Migration { name: string; @@ -40,6 +41,7 @@ const MIGRATIONS: Migration[] = [ }, { name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical }, { name: "0014_chat_read_state", up: up0014ChatReadState }, + { name: "0015_token_revocation", up: up0015TokenRevocation }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0015_token_revocation.ts b/api/db/migrations/0015_token_revocation.ts new file mode 100644 index 00000000..a2e0f0ed --- /dev/null +++ b/api/db/migrations/0015_token_revocation.ts @@ -0,0 +1,17 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Changing or resetting a password must end every session opened before it. +// Tokens are stateless JWTs, so each user carries a cut-off instead: +// `users.tokens_valid_after` (ISO), and a token issued before it is refused. +// NULL — the state every existing user starts in — means no cut-off. +// +// Idempotent: also runs against fresh databases created from schema.sql, where +// the column already exists. +export function up(db: DatabaseSync): void { + const cols = db.prepare(`PRAGMA table_info(users);`).all() as { + name: string; + }[]; + if (!cols.some((c) => c.name === "tokens_valid_after")) { + db.exec(`ALTER TABLE users ADD COLUMN tokens_valid_after TEXT;`); + } +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 6e4d74d5..49983982 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -31,7 +31,8 @@ CREATE TABLE users ( description TEXT, invited_by TEXT REFERENCES users(id), email TEXT NOT NULL, - chat_read_at TEXT + chat_read_at TEXT, + tokens_valid_after TEXT ); CREATE TABLE votes ( diff --git a/api/lib/auth.ts b/api/lib/auth.ts index 95f0871e..b4a46a4a 100644 --- a/api/lib/auth.ts +++ b/api/lib/auth.ts @@ -1,6 +1,67 @@ import type { Context } from "@oak/oak"; -import { verifyJWT } from "./jwt.ts"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { verifyJWT, verifyMediaToken } from "./jwt.ts"; +import { + APIErrorCode, + APIException, + type AuthPayload, + type Role, +} from "../model/interfaces.ts"; +import { db } from "../model/db.ts"; + +/** + * Checks a signature-verified token against the live user row: the user must + * still exist, and the token must not predate their last password change + * (`users.tokens_valid_after`). Returns the live role, or null to refuse. + * + * `iat` has one-second resolution, so a token issued in the same second as the + * change survives it; one issued after it (the fresh one the change hands back) + * always does. + */ +function checkTokenOwner( + userId: string, + iat: number | undefined, +): { role: Role } | null { + const row = db.prepare( + `SELECT role, tokens_valid_after FROM users WHERE id = ?;`, + ).get(userId) as + | { role: Role; tokens_valid_after: string | null } + | undefined; + if (!row) return null; + if (row.tokens_valid_after) { + const cutoff = Math.floor( + new Date(row.tokens_valid_after).getTime() / 1000, + ); + if ((iat ?? 0) < cutoff) return null; + } + return { role: row.role }; +} + +/** + * Verifies a session token end to end: signature, expiry, a user that still + * exists and no password change since it was issued. The role comes from the + * DB, not the token, since tokens last 7 days and roles change under them. + */ +export async function authenticate( + token: string, +): Promise { + const payload = await verifyJWT(token); + if (!payload) return null; + const live = checkTokenOwner(payload.userId, payload.iat); + if (!live) return null; + return { ...payload, role: live.role }; +} + +/** + * Resolves the `?token=` on a media URL to the viewer's user id. Takes a media + * token, or a session token from a client that predates them. + */ +export async function authenticateMedia(token: string): Promise { + const media = await verifyMediaToken(token); + if (media) { + return checkTokenOwner(media.userId, media.iat) ? media.userId : null; + } + return (await authenticate(token))?.userId ?? null; +} /** * Extracts the userId from an optional Bearer token. @@ -8,10 +69,10 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts"; * Distinguishes *missing* credentials from *invalid* ones: * - No `Authorization: Bearer …` header (or a placeholder value such as the * `Bearer undefined` that the client emits when logged out) → anonymous (null). - * - A real token that fails verification (expired / bad signature) → throws 401, - * so a stale client session surfaces as an auth error it can react to, instead - * of being silently downgraded to anonymous (which yields a misleading 404 on - * private resources). + * - A real token that fails verification (expired / bad signature / revoked) → + * throws 401, so a stale client session surfaces as an auth error it can react + * to, instead of being silently downgraded to anonymous (which yields a + * misleading 404 on private resources). */ export async function parseOptionalAuth(ctx: Context): Promise { const authHeader = ctx.request.headers.get("Authorization"); @@ -20,7 +81,7 @@ export async function parseOptionalAuth(ctx: Context): Promise { const token = authHeader.substring(7).trim(); if (!token || token === "undefined" || token === "null") return null; - const payload = await verifyJWT(token); + const payload = await authenticate(token); if (!payload) { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); } diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 4235617b..1408b707 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -1,4 +1,5 @@ -import { randomBytes, scrypt } from "node:crypto"; +import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; +import { Buffer } from "node:buffer"; import { jwtVerify, SignJWT } from "@panva/jose"; import { @@ -47,10 +48,40 @@ export async function createJWT( ): Promise { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) + // `iat` is what a password change revokes against (users.tokens_valid_after). + .setIssuedAt() .setExpirationTime("7d") .sign(JWT_KEY); } +// ── Media tokens ────────────────────────────────────────────────────────────── + +// Media elements can't send an Authorization header, so a private dump's file +// and thumbnail URLs carry a token in the query string, where it lands in +// access logs and browser history. That token is this one: it only reads media, +// so a leaked URL exposes the owner's private files, not their account. +export async function createMediaToken(userId: string): Promise { + return await new SignJWT({ purpose: "media", userId }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("7d") + .sign(JWT_KEY); +} + +export async function verifyMediaToken( + token: string, +): Promise<{ userId: string; iat?: number } | null> { + try { + const { payload } = await jwtVerify(token, JWT_KEY); + if (payload.purpose !== "media" || typeof payload.userId !== "string") { + return null; + } + return { userId: payload.userId, iat: payload.iat }; + } catch { + return null; + } +} + export async function verifyJWT(token: string): Promise { try { const { payload } = await jwtVerify(token, JWT_KEY); @@ -86,7 +117,13 @@ export function verifyPassword( return new Promise((resolve, reject) => { scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); - else resolve(hash === derivedKey.toString("hex")); + else { + const expected = Buffer.from(hash ?? "", "hex"); + resolve( + expected.length === derivedKey.length && + timingSafeEqual(expected, derivedKey), + ); + } }); }); } diff --git a/api/lib/public-address.ts b/api/lib/public-address.ts new file mode 100644 index 00000000..768c912d --- /dev/null +++ b/api/lib/public-address.ts @@ -0,0 +1,137 @@ +/** + * Guards for server-side fetches of user-supplied URLs (link previews, the + * image proxy, Bandcamp resolution): the server must not become a way to reach + * addresses only it can see — loopback, the LAN, container networks, or the + * cloud metadata endpoint at 169.254.169.254. + */ + +function parseIPv4(s: string): number[] | null { + const parts = s.split("."); + if (parts.length !== 4) return null; + const bytes = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN); + return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null; +} + +function isPrivateIPv4([a, b]: number[]): boolean { + return a === 0 || // "this" network + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT + (a === 169 && b === 254) || // link-local, cloud metadata + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || // IETF protocol assignments (192.0.0.0/24 …) + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || // benchmarking + a >= 224; // multicast, reserved, broadcast +} + +/** 16 bytes, or null when `s` isn't an IPv6 address. */ +function parseIPv6(s: string): number[] | null { + let addr = s.toLowerCase().replace(/%.*$/, ""); // drop a zone id + // An embedded dotted quad (::ffff:10.0.0.1) becomes the last two groups. + const lastColon = addr.lastIndexOf(":"); + if (lastColon === -1) return null; + const tail = addr.slice(lastColon + 1); + if (tail.includes(".")) { + const v4 = parseIPv4(tail); + if (!v4) return null; + addr = `${addr.slice(0, lastColon + 1)}${ + ((v4[0] << 8) | v4[1]).toString(16) + }:${((v4[2] << 8) | v4[3]).toString(16)}`; + } + const halves = addr.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + const fill = 8 - head.length - rest.length; + if (halves.length === 1 ? fill !== 0 : fill < 1) return null; + const groups = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...rest]; + if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null; + return groups.flatMap((g) => { + const n = parseInt(g, 16); + return [n >> 8, n & 0xff]; + }); +} + +function isPrivateIPv6(b: number[]): boolean { + const zeroPrefix = (n: number) => b.slice(0, n).every((x) => x === 0); + // IPv4 carried inside IPv6 is judged as the IPv4 address it carries. + if (zeroPrefix(10) && b[10] === 0xff && b[11] === 0xff) { // ::ffff:a.b.c.d + return isPrivateIPv4(b.slice(12)); + } + if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d + return true; + } + if ( // 64:ff9b::/96 NAT64 + b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && + b.slice(4, 12).every((x) => x === 0) + ) return isPrivateIPv4(b.slice(12)); + if (b[0] === 0x20 && b[1] === 0x02) { // 2002::/16 6to4 + return isPrivateIPv4(b.slice(2, 6)); + } + return (b[0] & 0xfe) === 0xfc || // fc00::/7 unique local + (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) || // fe80::/10 link-local + (b[0] === 0xfe && (b[1] & 0xc0) === 0xc0) || // fec0::/10 site-local + b[0] === 0xff; // multicast +} + +/** True for an IP literal outside the public internet. Non-IPs are false. */ +export function isPrivateAddress(ip: string): boolean { + const v4 = parseIPv4(ip); + if (v4) return isPrivateIPv4(v4); + const v6 = parseIPv6(ip); + return v6 ? isPrivateIPv6(v6) : false; +} + +function isIpLiteral(host: string): boolean { + return parseIPv4(host) !== null || parseIPv6(host) !== null; +} + +/** `URL.hostname` keeps the brackets around an IPv6 literal. */ +function bareHostname(url: URL): string { + return url.hostname.replace(/^\[(.*)\]$/, "$1"); +} + +/** + * The synchronous part of the check: a hostname that is a private IP literal, + * or a name that is local by definition. Names still need resolving (see + * resolvePublicAddress) — this only rejects what is obvious without DNS. + */ +export function isPrivateHostname(url: URL): boolean { + const host = bareHostname(url).toLowerCase(); + if (host === "localhost" || host.endsWith(".localhost")) return true; + return isPrivateAddress(host); +} + +/** + * Resolves `url`'s host and returns one of its addresses, or throws if the + * host is private or any address it resolves to is (a name with both a public + * and a private record is refused outright, since the fetch may pick either). + * + * The fetch itself resolves again, so a DNS server answering differently the + * second time (rebinding) isn't fully excluded; the curl fallback pins the + * address this returns. + */ +export async function resolvePublicAddress(url: URL): Promise { + if (isPrivateHostname(url)) { + throw new Error(`Refusing to fetch private address ${url.hostname}`); + } + const host = bareHostname(url); + if (isIpLiteral(host)) return host; + + const results = await Promise.allSettled([ + Deno.resolveDns(host, "A"), + Deno.resolveDns(host, "AAAA"), + ]); + const addresses = results.flatMap((r) => + r.status === "fulfilled" ? r.value : [] + ); + if (addresses.length === 0) { + throw new Error(`Could not resolve ${host}`); + } + const blocked = addresses.find(isPrivateAddress); + if (blocked) { + throw new Error(`Refusing to fetch ${host}: resolves to ${blocked}`); + } + return addresses[0]; +} diff --git a/api/lib/rate-limit.ts b/api/lib/rate-limit.ts new file mode 100644 index 00000000..baccbf38 --- /dev/null +++ b/api/lib/rate-limit.ts @@ -0,0 +1,39 @@ +// Token buckets keyed by the caller's choice (e.g. `${kind}:${userId}`) — per +// user rather than per socket or request, so more tabs or parallel requests +// don't buy more throughput. +interface Bucket { + tokens: number; + at: number; +} +const buckets = new Map(); + +export interface RateLimit { + /** Burst size. */ + capacity: number; + /** Tokens regained per second. */ + refill: number; +} + +/** Takes one token for `key`; false when the bucket is empty. */ +export function takeToken(key: string, limit: RateLimit): boolean { + const now = Date.now(); + const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now }; + bucket.tokens = Math.min( + limit.capacity, + bucket.tokens + ((now - bucket.at) / 1000) * limit.refill, + ); + bucket.at = now; + buckets.set(key, bucket); + if (bucket.tokens < 1) return false; + bucket.tokens -= 1; + return true; +} + +// Idle buckets have refilled by now for every limit in use; drop them so the +// map tracks only keys that have been active lately. +setInterval(() => { + const cutoff = Date.now() - 60 * 60_000; + for (const [key, bucket] of buckets) { + if (bucket.at < cutoff) buckets.delete(key); + } +}, 60_000); diff --git a/api/lib/self-url.ts b/api/lib/self-url.ts new file mode 100644 index 00000000..be823b27 --- /dev/null +++ b/api/lib/self-url.ts @@ -0,0 +1,23 @@ +import { FRONTEND_URL, PUBLIC_URL } from "../config.ts"; + +const SELF_HOSTS = new Set( + [PUBLIC_URL, FRONTEND_URL].flatMap((u) => { + try { + return [new URL(u).host.toLowerCase()]; + } catch { + return []; + } + }), +); + +const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/; + +/** + * A link to one of this site's own dump, user or playlist pages. Matched on the + * host as well as the path: `/users/bob` on some other site is someone else's + * page, not ours. + */ +export function isSelfUrl(url: URL): boolean { + return SELF_HOSTS.has(url.host.toLowerCase()) && + SELF_PATH_RE.test(url.pathname); +} diff --git a/api/lib/upload.ts b/api/lib/upload.ts index af6bf11d..f802a7fb 100644 --- a/api/lib/upload.ts +++ b/api/lib/upload.ts @@ -65,6 +65,7 @@ export async function serveUploadedFile( filePath: string, mime: string, cacheMaxAge = 3600, + isPrivate = false, ): Promise { let data: Uint8Array; try { @@ -75,7 +76,11 @@ export async function serveUploadedFile( } ctx.response.headers.set("Content-Type", mime); ctx.response.headers.set("Content-Disposition", "inline"); - ctx.response.headers.set("Cache-Control", `public, max-age=${cacheMaxAge}`); + ctx.response.headers.set("X-Content-Type-Options", "nosniff"); + ctx.response.headers.set( + "Cache-Control", + `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`, + ); ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); ctx.response.body = data; return true; diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index e809e85a..3bfc8877 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -4,13 +4,12 @@ import { type RouterContext, type State, } from "@oak/oak"; -import { verifyJWT } from "../lib/jwt.ts"; +import { authenticate } from "../lib/auth.ts"; import { APIErrorCode, APIException, type AuthPayload, } from "../model/interfaces.ts"; -import { getUserById } from "../services/user-service.ts"; import { can, type Permission } from "../lib/permissions.ts"; export interface AuthState extends State { @@ -40,23 +39,15 @@ export async function authMiddleware( } const token = authHeader.substring(7); - const payload = await verifyJWT(token); + // Also checks the user still exists and hasn't changed their password since + // the token was issued, and takes the live role from the DB. + const payload = await authenticate(token); if (!payload) { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); } - let user; - try { - user = getUserById(payload.userId); - } catch { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "User not found"); - } - - // Trust the live role from the DB, not the role baked into the JWT at login. - // Tokens last 7 days, so a role change (e.g. promotion to admin or a demotion) - // would otherwise not take effect until the user re-logs in. - ctx.state.user = { ...payload, role: user.role }; + ctx.state.user = payload; await next(); } diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 0deea7ef..302bc7c4 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -220,11 +220,14 @@ export interface AuthPayload { username: string; role: Role; exp: number; + iat?: number; } export function isAuthPayload(obj: unknown): obj is AuthPayload { return !!obj && typeof obj === "object" && + // Purpose-scoped tokens (password reset, media) are never session tokens. + !("purpose" in obj) && "userId" in obj && typeof obj.userId === "string" && "username" in obj && typeof obj.username === "string" && "role" in obj && isRole(obj.role) && diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 5b8dbc05..cc6514ff 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -37,6 +37,10 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts"; const router = new Router({ prefix: "/api/dumps" }); +// Every route here takes a slug or an id; the services below them key on the +// id, so mutations always pass `dump.id` from the resolved dump, never the raw +// parameter. + // Categories arrive on the multipart create path as a JSON-encoded string field. function parseCategoryIds(value: FormDataEntryValue | null): string[] { if (typeof value !== "string" || !value) return []; @@ -193,7 +197,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { } const updatedDump = await replaceFileDump( - dumpId, + dump.id, file, typeof comment === "string" && comment ? comment : undefined, typeof title === "string" && title ? title : undefined, @@ -228,7 +232,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { ); } - const updatedDump = await updateDump(dumpId, body); + const updatedDump = await updateDump(dump.id, body); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); @@ -306,7 +310,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => { ); } - const updatedDump = await refreshDumpMetadata(dumpId); + const updatedDump = await refreshDumpMetadata(dump.id); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); @@ -324,7 +328,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => { ); } - await deleteDump(dumpId); + await deleteDump(dump.id); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; diff --git a/api/routes/files.ts b/api/routes/files.ts index c8a30128..4f6e4a29 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -2,7 +2,7 @@ import { Router } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { getDump } from "../services/dump-service.ts"; import { DUMPS_DIR } from "../lib/upload.ts"; -import { verifyJWT } from "../lib/jwt.ts"; +import { authenticateMedia } from "../lib/auth.ts"; const router = new Router({ prefix: "/api/files" }); @@ -14,15 +14,31 @@ const router = new Router({ prefix: "/api/files" }); * ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the * full UTF-8 name for modern browsers. */ -function contentDisposition(name: string): string { +function contentDisposition( + type: "inline" | "attachment", + name: string, +): string { const asciiFallback = name .replace(/["\\]/g, "_") .replace(/[^\x20-\x7e]/g, "_"); - return `inline; filename="${asciiFallback}"; filename*=UTF-8''${ + return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${ encodeURIComponent(name) }`; } +// The MIME type of an upload is whatever the uploader's browser claimed, and +// these responses come from the app's own origin — where the session token +// lives. Only types a browser renders without running the file's own code are +// served inline; anything else (text/html, image/svg+xml, XML, …) downloads. +// ,