Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
53 changes: 53 additions & 0 deletions server/app/api/api.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
39 changes: 34 additions & 5 deletions server/app/api/pastes/[id]/unlock/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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(
Expand All @@ -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 });
Expand Down
81 changes: 20 additions & 61 deletions server/app/api/pastes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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<CreateBody>(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(
Expand Down Expand Up @@ -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) };
}
14 changes: 13 additions & 1 deletion server/lib/password.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
Expand Down
29 changes: 27 additions & 2 deletions server/lib/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$<salt_b64>$<hash_b64>`
*/
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);
Expand All @@ -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;

Expand Down
15 changes: 14 additions & 1 deletion server/lib/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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" };
}
Expand Down
Loading
Loading