diff --git a/CHANGELOG.md b/CHANGELOG.md index 324d0e4..fd6e01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Security:** production rejects empty/known-placeholder/`<16` char `PASTE_AUTH_SECRET`; Compose no longer ships a working default secret - **Security:** default `TRUSTED_PROXY_HOPS=0` (ignore spoofable XFF on direct deploys); in-memory rate limiters prune/evict keys; set hops `1` behind a reverse proxy +- **Security:** bound JSON create and unlock request bodies; max password length 1024 (reject before scrypt) - Theme FOUC boot script is a static string (no `JSON.stringify` interpolation) to clear CodeQL `js/bad-code-sanitization` - CI workflow sets explicit `permissions: contents: read` (CodeQL `actions/missing-workflow-permissions`) - `stripAnsi` uses a linear scan instead of nested quantifier regexes (CodeQL `js/polynomial-redos`) diff --git a/server/app/api/api.integration.test.ts b/server/app/api/api.integration.test.ts index b417747..6c935f5 100644 --- a/server/app/api/api.integration.test.ts +++ b/server/app/api/api.integration.test.ts @@ -238,6 +238,59 @@ describe("API integration", () => { expect(limited.headers.get("Retry-After")).toBeTruthy(); }); + it("rejects oversize JSON create body with 413", async () => { + const prev = process.env.MAX_PASTE_SIZE; + process.env.MAX_PASTE_SIZE = "64"; + try { + // Exceeds MAX_PASTE_SIZE + JSON overhead before parse + const huge = "y".repeat(20_000); + const res = await createPasteRoute( + new Request("http://test.local/api/pastes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: huge }), + }), + ); + expect(res.status).toBe(413); + } finally { + if (prev === undefined) delete process.env.MAX_PASTE_SIZE; + else process.env.MAX_PASTE_SIZE = prev; + } + }); + + it("rejects oversize password on create and unlock", async () => { + const { MAX_PASSWORD_LENGTH } = await import("@/lib/password"); + const hugePw = "p".repeat(MAX_PASSWORD_LENGTH + 1); + const created = await createPasteRoute( + new Request("http://test.local/api/pastes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "x", password: hugePw }), + }), + ); + expect(created.status).toBe(400); + + const ok = await createPasteRoute( + new Request("http://test.local/api/pastes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "x", password: "ok-password" }), + }), + ); + expect(ok.status).toBe(201); + const { id } = await ok.json(); + + const unlock = await unlockPasteRoute( + new Request(`http://test.local/api/pastes/${id}/unlock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: hugePw }), + }), + { params: Promise.resolve({ id }) }, + ); + expect(unlock.status).toBe(400); + }); + it("accepts text/plain streaming body for create", async () => { const res = await createPasteRoute( new Request("http://test.local/api/pastes", { diff --git a/server/app/api/pastes/[id]/unlock/route.ts b/server/app/api/pastes/[id]/unlock/route.ts index de46a10..ce1d43e 100644 --- a/server/app/api/pastes/[id]/unlock/route.ts +++ b/server/app/api/pastes/[id]/unlock/route.ts @@ -7,7 +7,15 @@ import { import { getDb } from "@/lib/db"; import { recordMetric } from "@/lib/metrics"; import { unlockPaste } from "@/lib/paste"; +import { + MAX_PASSWORD_LENGTH, + validatePasswordInput, +} from "@/lib/password"; import { checkRateLimit, clientKeyFromRequest } from "@/lib/rate-limit"; +import { + UNLOCK_MAX_BODY_BYTES, + readJsonBodyWithLimit, +} from "@/lib/request-body"; export const runtime = "nodejs"; @@ -33,12 +41,20 @@ export async function POST(request: Request, context: RouteContext) { ); } - let body: { password?: unknown }; - try { - body = (await request.json()) as { password?: unknown }; - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + const parsed = await readJsonBodyWithLimit<{ password?: unknown }>( + request, + UNLOCK_MAX_BODY_BYTES, + { + exceedMessage: `request body exceeds maximum size of ${UNLOCK_MAX_BODY_BYTES} bytes`, + }, + ); + if (!parsed.ok) { + return NextResponse.json( + { error: parsed.error }, + { status: parsed.status }, + ); } + const body = parsed.value; if (typeof body.password !== "string" || body.password.length === 0) { return NextResponse.json( @@ -47,6 +63,19 @@ export async function POST(request: Request, context: RouteContext) { ); } + const pwCheck = validatePasswordInput(body.password); + if (!pwCheck.ok) { + return NextResponse.json( + { + error: + body.password.length > MAX_PASSWORD_LENGTH + ? pwCheck.error + : "password is required", + }, + { status: 400 }, + ); + } + const result = unlockPaste(getDb(), id, body.password); if (!result.ok) { return NextResponse.json({ error: result.error }, { status: result.status }); diff --git a/server/app/api/pastes/route.ts b/server/app/api/pastes/route.ts index 7bde642..48bb481 100644 --- a/server/app/api/pastes/route.ts +++ b/server/app/api/pastes/route.ts @@ -8,6 +8,11 @@ import { clientKeyFromRequest, } from "@/lib/rate-limit"; import { getMaxPasteSize } from "@/lib/env"; +import { + JSON_CREATE_OVERHEAD_BYTES, + readBodyWithLimit, + readJsonBodyWithLimit, +} from "@/lib/request-body"; import { buildPasteUrl } from "@/lib/urls"; export const runtime = "nodejs"; @@ -69,14 +74,16 @@ export async function POST(request: Request) { const contentType = request.headers.get("content-type") ?? ""; const db = getDb(); purgeExpiredPastes(db); + const max = getMaxPasteSize(); // Streaming / raw body path (CLI large stdin) if ( contentType.includes("text/plain") || contentType.includes("application/octet-stream") ) { - const max = getMaxPasteSize(); - const buf = await readBodyWithLimit(request, max); + const buf = await readBodyWithLimit(request, max, { + exceedMessage: `content exceeds MAX_PASTE_SIZE (${max} bytes)`, + }); if (!buf.ok) { return NextResponse.json({ error: buf.error }, { status: buf.status }); } @@ -90,12 +97,18 @@ export async function POST(request: Request) { return createResponse(request, result); } - let body: CreateBody; - try { - body = (await request.json()) as CreateBody; - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + // JSON path: hard byte limit before parse (content + wrapper overhead) + const jsonMax = max + JSON_CREATE_OVERHEAD_BYTES; + const parsed = await readJsonBodyWithLimit(request, jsonMax, { + exceedMessage: `request body exceeds maximum size of ${jsonMax} bytes`, + }); + if (!parsed.ok) { + return NextResponse.json( + { error: parsed.error }, + { status: parsed.status }, + ); } + const body = parsed.value; if (typeof body.content !== "string") { return NextResponse.json( @@ -126,57 +139,3 @@ export async function POST(request: Request) { return createResponse(request, result); } - -async function readBodyWithLimit( - request: Request, - maxBytes: number, -): Promise< - | { ok: true; text: string } - | { ok: false; status: number; error: string } -> { - const cl = request.headers.get("content-length"); - if (cl) { - const n = Number.parseInt(cl, 10); - if (Number.isFinite(n) && n > maxBytes) { - return { - ok: false, - status: 413, - error: `content exceeds MAX_PASTE_SIZE (${maxBytes} bytes)`, - }; - } - } - - if (!request.body) { - return { ok: true, text: "" }; - } - - const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - total += value.byteLength; - if (total > maxBytes) { - try { - await reader.cancel(); - } catch { - /* ignore */ - } - return { - ok: false, - status: 413, - error: `content exceeds MAX_PASTE_SIZE (${maxBytes} bytes)`, - }; - } - chunks.push(value); - } - const merged = new Uint8Array(total); - let offset = 0; - for (const c of chunks) { - merged.set(c, offset); - offset += c.byteLength; - } - return { ok: true, text: new TextDecoder("utf-8").decode(merged) }; -} diff --git a/server/lib/password.test.ts b/server/lib/password.test.ts index 6e8ba73..dbdcb20 100644 --- a/server/lib/password.test.ts +++ b/server/lib/password.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { hashPassword, verifyPassword } from "./password"; +import { + MAX_PASSWORD_LENGTH, + hashPassword, + validatePasswordInput, + verifyPassword, +} from "./password"; describe("password hashing", () => { it("hashes and verifies correct password", () => { @@ -21,6 +26,13 @@ describe("password hashing", () => { expect(() => hashPassword("")).toThrow(/empty/i); }); + it("rejects oversize passwords without hashing", () => { + const huge = "x".repeat(MAX_PASSWORD_LENGTH + 1); + expect(validatePasswordInput(huge).ok).toBe(false); + expect(() => hashPassword(huge)).toThrow(/at most/i); + expect(verifyPassword(huge, "scrypt$a$b")).toBe(false); + }); + it("returns false for malformed stored hashes", () => { expect(verifyPassword("x", "not-a-hash")).toBe(false); expect(verifyPassword("x", "scrypt$onlyone")).toBe(false); diff --git a/server/lib/password.ts b/server/lib/password.ts index 5d300cf..4db9b4f 100644 --- a/server/lib/password.ts +++ b/server/lib/password.ts @@ -3,12 +3,35 @@ import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; const SCRYPT_KEYLEN = 64; const SCRYPT_OPTS = { N: 16384, r: 8, p: 1 } as const; +/** Max password length (chars) to bound scrypt CPU and request abuse. */ +export const MAX_PASSWORD_LENGTH = 1024; + +/** + * Validate password for create/unlock. Empty is rejected; oversize is rejected + * before any scrypt work. + */ +export function validatePasswordInput( + password: string, +): { ok: true } | { ok: false; error: string } { + if (!password) { + return { ok: false, error: "Password must not be empty" }; + } + if (password.length > MAX_PASSWORD_LENGTH) { + return { + ok: false, + error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters`, + }; + } + return { ok: true }; +} + /** * Hash a password with scrypt. Format: `scrypt$$` */ export function hashPassword(password: string): string { - if (!password) { - throw new Error("Password must not be empty"); + const check = validatePasswordInput(password); + if (!check.ok) { + throw new Error(check.error); } const salt = randomBytes(16); const hash = scryptSync(password, salt, SCRYPT_KEYLEN, SCRYPT_OPTS); @@ -17,9 +40,11 @@ export function hashPassword(password: string): string { /** * Verify a password against a stored hash. Returns false for malformed hashes. + * Oversize passwords are rejected without running scrypt. */ export function verifyPassword(password: string, stored: string): boolean { if (!password || !stored) return false; + if (password.length > MAX_PASSWORD_LENGTH) return false; const parts = stored.split("$"); if (parts.length !== 3 || parts[0] !== "scrypt") return false; diff --git a/server/lib/paste.ts b/server/lib/paste.ts index 48f8119..6bd4f3c 100644 --- a/server/lib/paste.ts +++ b/server/lib/paste.ts @@ -9,7 +9,11 @@ import { type PasteMetadata, } from "./metadata"; import { parseExpire } from "./parse-expire"; -import { hashPassword, verifyPassword } from "./password"; +import { + hashPassword, + validatePasswordInput, + verifyPassword, +} from "./password"; import { pastes, type PasteRow } from "./schema"; const nanoid = customAlphabet( @@ -93,6 +97,10 @@ export function createPaste( let passwordHash: string | null = null; let isEncrypted = false; if (input.password != null && input.password !== "") { + const pw = validatePasswordInput(input.password); + if (!pw.ok) { + return { ok: false, status: 400, error: pw.error }; + } passwordHash = hashPassword(input.password); isEncrypted = true; } @@ -174,6 +182,11 @@ export function unlockPaste( return { ok: false, status: 400, error: "Paste is not password-protected" }; } + const pw = validatePasswordInput(password); + if (!pw.ok) { + return { ok: false, status: 400, error: pw.error }; + } + if (!verifyPassword(password, row.passwordHash)) { return { ok: false, status: 401, error: "Invalid password" }; } diff --git a/server/lib/request-body.test.ts b/server/lib/request-body.test.ts new file mode 100644 index 0000000..819a70c --- /dev/null +++ b/server/lib/request-body.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + readBodyWithLimit, + readJsonBodyWithLimit, +} from "./request-body"; + +describe("readBodyWithLimit", () => { + it("reads a small body", async () => { + const req = new Request("http://t/", { + method: "POST", + body: "hello", + }); + const r = await readBodyWithLimit(req, 100); + expect(r).toEqual({ ok: true, text: "hello" }); + }); + + it("rejects when Content-Length exceeds max", async () => { + const req = new Request("http://t/", { + method: "POST", + headers: { "Content-Length": "999" }, + body: "x", + }); + const r = await readBodyWithLimit(req, 10); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.status).toBe(413); + } + }); + + it("rejects when streamed body exceeds max", async () => { + const req = new Request("http://t/", { + method: "POST", + body: "a".repeat(50), + }); + const r = await readBodyWithLimit(req, 10); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.status).toBe(413); + } + }); +}); + +describe("readJsonBodyWithLimit", () => { + it("parses JSON within limit", async () => { + const req = new Request("http://t/", { + method: "POST", + body: JSON.stringify({ password: "secret" }), + }); + const r = await readJsonBodyWithLimit<{ password: string }>(req, 1024); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value.password).toBe("secret"); + }); + + it("rejects invalid JSON", async () => { + const req = new Request("http://t/", { + method: "POST", + body: "not-json", + }); + const r = await readJsonBodyWithLimit(req, 1024); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.status).toBe(400); + }); +}); diff --git a/server/lib/request-body.ts b/server/lib/request-body.ts new file mode 100644 index 0000000..585d324 --- /dev/null +++ b/server/lib/request-body.ts @@ -0,0 +1,89 @@ +/** + * Bounded request body readers (DoS protection). + * Prefer these over unbounded request.json() / request.text(). + */ + +export type ReadBodyResult = + | { ok: true; text: string } + | { ok: false; status: number; error: string }; + +/** + * Read the request body as UTF-8 text, aborting once `maxBytes` is exceeded. + * Honors Content-Length when present for an early 413. + */ +export async function readBodyWithLimit( + request: Request, + maxBytes: number, + options?: { exceedMessage?: string }, +): Promise { + const exceedMessage = + options?.exceedMessage ?? + `request body exceeds maximum size of ${maxBytes} bytes`; + + const cl = request.headers.get("content-length"); + if (cl) { + const n = Number.parseInt(cl, 10); + if (Number.isFinite(n) && n > maxBytes) { + return { ok: false, status: 413, error: exceedMessage }; + } + } + + if (!request.body) { + return { ok: true, text: "" }; + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBytes) { + try { + await reader.cancel(); + } catch { + /* ignore */ + } + return { ok: false, status: 413, error: exceedMessage }; + } + chunks.push(value); + } + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.byteLength; + } + return { ok: true, text: new TextDecoder("utf-8").decode(merged) }; +} + +/** + * Read a JSON body with a hard byte limit, then JSON.parse. + */ +export async function readJsonBodyWithLimit( + request: Request, + maxBytes: number, + options?: { exceedMessage?: string }, +): Promise< + | { ok: true; value: T } + | { ok: false; status: number; error: string } +> { + const raw = await readBodyWithLimit(request, maxBytes, options); + if (!raw.ok) return raw; + if (!raw.text.trim()) { + return { ok: false, status: 400, error: "Invalid JSON body" }; + } + try { + return { ok: true, value: JSON.parse(raw.text) as T }; + } catch { + return { ok: false, status: 400, error: "Invalid JSON body" }; + } +} + +/** Extra allowance for JSON wrapper keys around paste content. */ +export const JSON_CREATE_OVERHEAD_BYTES = 16_384; + +/** Unlock body is only `{ password }` — keep small. */ +export const UNLOCK_MAX_BODY_BYTES = 4_096;