Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
201 lines
7.5 KiB
TypeScript
201 lines
7.5 KiB
TypeScript
/**
|
|
* Request / Upload Size Limits Suite
|
|
*
|
|
* Pure logic — no network, no database. Verifies that the backend refuses
|
|
* oversized requests BEFORE any body is buffered:
|
|
*
|
|
* 1. `contentLengthExceeded` / `guardBodySize` read the Content-Length header
|
|
* only, so a 2-GB upload never reaches `formData()` / `json()` / `text()`.
|
|
* 2. `readJsonSized` re-measures the serialized body after parsing, which
|
|
* catches chunked requests without a Content-Length header.
|
|
* 3. The scan route's 413 must fire before any database code runs.
|
|
*/
|
|
|
|
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
|
import {
|
|
contentLengthExceeded,
|
|
guardBodySize,
|
|
readJsonSized,
|
|
} from "../../src/lib/http/requestSize";
|
|
import { MAX_UPLOAD_BYTES, MAX_JSON_BODY_BYTES } from "../../src/lib/limits";
|
|
import { issueCsrfToken, CSRF_COOKIE, CSRF_HEADER } from "../../src/lib/auth/csrf";
|
|
import { POST as scanPOST } from "../../src/app/api/scan/route";
|
|
import type { NextRequest } from "next/server";
|
|
|
|
const MB = 1024 * 1024;
|
|
|
|
/** Minimal Request stand-in carrying only the headers the guard reads. */
|
|
function headerOnlyRequest(headerName: string, value: string): Request {
|
|
return { headers: new Headers({ [headerName]: value }) } as unknown as Request;
|
|
}
|
|
|
|
/**
|
|
* Builds a scan request that clears the route's CSRF double-submit check so the
|
|
* size guard is actually reached: matching `sr_csrf` cookie + `x-csrf-token`
|
|
* header (origin is absent, which the allow-list accepts for non-browser
|
|
* callers). The size guard runs AFTER the CSRF check and BEFORE any body
|
|
* parsing, so the 413 assertion below exercises exactly that ordering.
|
|
*/
|
|
function scanRequest(contentLength: string): Request {
|
|
const token = issueCsrfToken();
|
|
return new Request("http://localhost/api/scan", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-length": contentLength,
|
|
cookie: `${CSRF_COOKIE}=${token}`,
|
|
[CSRF_HEADER]: token,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("Content-Length guard — upload limit (10 MB)", () => {
|
|
test("11 MB content-length is rejected with 413", () => {
|
|
const req = headerOnlyRequest("content-length", String(11 * MB));
|
|
expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(true);
|
|
const res = guardBodySize(req, MAX_UPLOAD_BYTES);
|
|
expect(res).not.toBeNull();
|
|
expect(res!.status).toBe(413);
|
|
});
|
|
|
|
test("5 MB content-length passes the guard", () => {
|
|
const req = headerOnlyRequest("content-length", String(5 * MB));
|
|
expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(false);
|
|
expect(guardBodySize(req, MAX_UPLOAD_BYTES)).toBeNull();
|
|
});
|
|
|
|
test("exactly 10 MB passes the guard (boundary is exclusive)", () => {
|
|
const req = headerOnlyRequest("content-length", String(MAX_UPLOAD_BYTES));
|
|
expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(false);
|
|
expect(guardBodySize(req, MAX_UPLOAD_BYTES)).toBeNull();
|
|
});
|
|
|
|
test("10 MB + 1 byte is rejected", () => {
|
|
const req = headerOnlyRequest("content-length", String(MAX_UPLOAD_BYTES + 1));
|
|
expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(true);
|
|
const res = guardBodySize(req, MAX_UPLOAD_BYTES);
|
|
expect(res).not.toBeNull();
|
|
expect(res!.status).toBe(413);
|
|
});
|
|
|
|
test("413 response carries the machine-readable error and limit", async () => {
|
|
const res = guardBodySize(
|
|
headerOnlyRequest("content-length", String(MAX_UPLOAD_BYTES + 1)),
|
|
MAX_UPLOAD_BYTES
|
|
)!;
|
|
const payload = await res.json();
|
|
expect(payload.error).toBe("request_too_large");
|
|
expect(payload.maxBytes).toBe(MAX_UPLOAD_BYTES);
|
|
});
|
|
|
|
test("malformed or negative content-length is not treated as exceeded", () => {
|
|
for (const bad of ["abc", "-5", ""]) {
|
|
const req = headerOnlyRequest("content-length", bad);
|
|
expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(false);
|
|
expect(guardBodySize(req, MAX_UPLOAD_BYTES)).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("missing content-length header (chunked) passes the header guard", () => {
|
|
const req = { headers: new Headers() } as unknown as Request;
|
|
expect(contentLengthExceeded(req, MAX_UPLOAD_BYTES)).toBe(false);
|
|
expect(guardBodySize(req, MAX_UPLOAD_BYTES)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("readJsonSized — JSON body limit (1 MB)", () => {
|
|
test("body larger than 1 MB (serialized length) is rejected with 413", async () => {
|
|
const big = { data: "x".repeat(MAX_JSON_BODY_BYTES + 10) };
|
|
const req = new Request("http://localhost/api/test", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(big),
|
|
});
|
|
|
|
const result = await readJsonSized<unknown>(req, MAX_JSON_BODY_BYTES);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.response.status).toBe(413);
|
|
const payload = await result.response.json();
|
|
expect(payload.error).toBe("request_too_large");
|
|
}
|
|
});
|
|
|
|
test("content-length over 1 MB is rejected before parsing", async () => {
|
|
const req = new Request("http://localhost/api/test", {
|
|
method: "POST",
|
|
headers: { "content-length": String(MAX_JSON_BODY_BYTES + 1) },
|
|
});
|
|
|
|
const result = await readJsonSized<unknown>(req, MAX_JSON_BODY_BYTES);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.response.status).toBe(413);
|
|
}
|
|
});
|
|
|
|
test("valid small body returns ok with the parsed body", async () => {
|
|
const req = new Request("http://localhost/api/test", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ email: "timo@example.com", remember: true }),
|
|
});
|
|
|
|
const result = await readJsonSized<{ email: string; remember: boolean }>(
|
|
req,
|
|
MAX_JSON_BODY_BYTES
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.body).toEqual({ email: "timo@example.com", remember: true });
|
|
}
|
|
});
|
|
|
|
test("broken JSON returns 400 invalid_json", async () => {
|
|
const req = new Request("http://localhost/api/test", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: "{this is not json",
|
|
});
|
|
|
|
const result = await readJsonSized<unknown>(req, MAX_JSON_BODY_BYTES);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.response.status).toBe(400);
|
|
const payload = await result.response.json();
|
|
expect(payload.error).toBe("invalid_json");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Scan route — oversized upload rejected before body/DB access", () => {
|
|
test("POST /api/scan with content-length > 10 MB and no body returns 413", async () => {
|
|
const req = scanRequest(String(MAX_UPLOAD_BYTES + 1));
|
|
|
|
// The guard runs before formData()/DB: a 413 here proves the route never
|
|
// tried to buffer the (absent) body and never touched the database.
|
|
const res = await scanPOST(req as unknown as NextRequest);
|
|
expect(res.status).toBe(413);
|
|
const payload = await res.json();
|
|
expect(payload.error).toBe("request_too_large");
|
|
});
|
|
|
|
test("POST /api/scan exactly at the 10 MB boundary is not rejected by the header guard", async () => {
|
|
const req = scanRequest(String(MAX_UPLOAD_BYTES));
|
|
|
|
const res = await scanPOST(req as unknown as NextRequest);
|
|
// With no multipart body the route must NOT answer 413 — it fails later,
|
|
// inside the formData/validation path (500), which proves the guard did not
|
|
// over-trigger at the exact boundary.
|
|
expect(res.status).not.toBe(413);
|
|
});
|
|
});
|
|
|
|
if (typeof require !== "undefined" && require.main === module) {
|
|
runAllTests()
|
|
.then((ok) => process.exit(ok ? 0 : 1))
|
|
.catch((err) => {
|
|
console.error("Fatal runner crash:", err);
|
|
process.exit(1);
|
|
});
|
|
}
|