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. +// ,