v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-24 20:07:33 +00:00
parent 3a09d0e354
commit 2ad8ae73d5
38 changed files with 1032 additions and 352 deletions

View File

@@ -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<AuthPayload | null> {
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<string | null> {
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<string | null> {
const authHeader = ctx.request.headers.get("Authorization");
@@ -20,7 +81,7 @@ export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
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");
}

View File

@@ -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<string> {
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<string> {
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<AuthPayload | null> {
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),
);
}
});
});
}

137
api/lib/public-address.ts Normal file
View File

@@ -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<string> {
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];
}

39
api/lib/rate-limit.ts Normal file
View File

@@ -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<string, Bucket>();
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);

23
api/lib/self-url.ts Normal file
View File

@@ -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);
}

View File

@@ -65,6 +65,7 @@ export async function serveUploadedFile(
filePath: string,
mime: string,
cacheMaxAge = 3600,
isPrivate = false,
): Promise<boolean> {
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;