From 46b3434f08e46936a8b8897b3b5204e9152cf8fc Mon Sep 17 00:00:00 2001 From: Frank Barrett Date: Mon, 13 Jul 2026 00:46:36 -0700 Subject: [PATCH 1/2] fix: harden application security boundaries Prevent cross-account offline replay, make sessions revocable, bound untrusted request bodies, and require explicit AI metadata consent. Store new attachments privately behind owner/share authorization and verify database TLS by default. Co-Authored-By: OpenAI Codex --- .env.example | 25 +++++-- README.md | 41 ++++++++--- SECURITY.md | 44 ++++++++++++ __tests__/appUrl.test.ts | 26 +++++++ __tests__/dbCaCert.test.ts | 28 +++++++- __tests__/imageUpload.test.ts | 23 ++++++ __tests__/nativeExchangeRoute.test.ts | 5 ++ __tests__/noteCreateIdempotency.test.ts | 3 +- __tests__/offlineDb.test.ts | 35 +++++++++ __tests__/password.test.ts | 13 +++- __tests__/requestBody.test.ts | 58 +++++++++++++++ __tests__/titleModelSecurity.test.ts | 34 +++++++++ __tests__/titleRoute.test.ts | 33 ++++++++- __tests__/uploadReadRoute.test.ts | 69 ++++++++++++++++++ app/api/analytics/route.ts | 16 ++++- app/api/auth/register/route.ts | 22 ++++-- app/api/auth/resend/route.ts | 20 +++++- app/api/auth/revoke/route.ts | 30 ++++++++ app/api/auth/verify/route.ts | 3 +- app/api/native/exchange/route.ts | 20 ++++-- app/api/notes/[id]/route.ts | 34 ++++++++- app/api/notes/[id]/share/route.ts | 18 +++-- app/api/notes/import/route.ts | 19 +++-- app/api/notes/route.ts | 32 +++++++-- app/api/notes/title/route.ts | 37 +++++++--- app/api/upload/route.ts | 96 +++++++++++++++++-------- app/api/uploads/[id]/route.ts | 83 +++++++++++++++++++++ app/layout.tsx | 2 + app/p/[token]/page.tsx | 14 +++- auth.config.ts | 2 +- auth.ts | 36 ++++++++-- components/AuthSessionSync.tsx | 23 ++++++ components/Header.tsx | 44 +++++------- components/SignOutButton.tsx | 28 ++++++++ lib/appUrl.ts | 16 +++++ lib/audit.ts | 1 + lib/db.ts | 46 +++++++++--- lib/imageUpload.ts | 36 ++++++++++ lib/noteLimits.ts | 1 + lib/offlineDb.ts | 38 +++++----- lib/password.ts | 40 ++++++++--- lib/requestBody.ts | 95 ++++++++++++++++++++++++ lib/storage.ts | 90 +++++++++++++++++++---- lib/titleModel.ts | 9 +-- next.config.mjs | 18 +++-- proxy.ts | 27 ++++--- types/next-auth.d.ts | 10 +++ vitest.setup.ts | 4 +- 48 files changed, 1257 insertions(+), 190 deletions(-) create mode 100644 SECURITY.md create mode 100644 __tests__/appUrl.test.ts create mode 100644 __tests__/imageUpload.test.ts create mode 100644 __tests__/requestBody.test.ts create mode 100644 __tests__/titleModelSecurity.test.ts create mode 100644 __tests__/uploadReadRoute.test.ts create mode 100644 app/api/auth/revoke/route.ts create mode 100644 app/api/uploads/[id]/route.ts create mode 100644 components/AuthSessionSync.tsx create mode 100644 components/SignOutButton.tsx create mode 100644 lib/appUrl.ts create mode 100644 lib/imageUpload.ts create mode 100644 lib/requestBody.ts diff --git a/.env.example b/.env.example index 87001d1..0338eca 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,17 @@ # Postgres connection string used by lib/db.ts. -# For a self-hosted Postgres with a self-signed cert, keep sslmode=require — -# lib/db.ts honors it but disables cert verification (rejectUnauthorized: false). -# For a managed provider (Neon, Supabase, RDS, etc.) the same URL format works. +# TLS certificates are verified against the system trust store by default. +# Managed providers normally work with sslmode=require as written below. DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DBNAME?sslmode=require # Optional. CA certificate used to verify the Postgres server's TLS identity — # either the PEM contents inline or a path to a .pem/.crt file. When set, the -# connection verifies the server (rejectUnauthorized: true); when unset, the -# connection uses TLS but the server identity is not verified (fine for a -# self-signed Postgres on the same host, weaker over an untrusted network). +# connection verifies the server against that CA. When unset, system CAs apply. DATABASE_CA_CERT= +# Local development only. Set this alongside sslmode=no-verify when a loopback +# Postgres uses a self-signed certificate. Never enable it across a network. +DATABASE_TLS_INSECURE=false + # Auth.js (NextAuth v5) session signing key. Generate with: openssl rand -base64 32 AUTH_SECRET= @@ -24,6 +25,8 @@ AUTH_GOOGLE_SECRET= # Optional. Anthropic key for AI note titles/summaries (Claude Haiku). If unset, # Keep falls back to local zero-token title inference. ANTHROPIC_API_KEY also works. ANTHROPIC_KEY= +# Explicit privacy opt-in: when true, note text is sent to Anthropic for metadata. +AI_METADATA_ENABLED=false # Override the model (default: claude-haiku-4-5-20251001). ANTHROPIC_MODEL= @@ -50,3 +53,13 @@ LOG_LEVEL= # Optional. Email of the account allowed to view the /analytics dashboard. # Unset → the dashboard 404s for everyone (analytics are still collected). ANALYTICS_ADMIN_EMAIL= + +# Optional private image storage. Choose S3-compatible storage or Vercel Blob. +# Keep the S3 bucket private; the application streams authorized objects. +S3_BUCKET= +S3_REGION=us-east-1 +S3_ENDPOINT= +S3_FORCE_PATH_STYLE=false +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +BLOB_READ_WRITE_TOKEN= diff --git a/README.md b/README.md index ca3c28e..565c2fb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Postgres, with a guest mode that keeps notes in the browser until sign-in. ## Features - Debounced autosave for new and existing notes -- LLM-generated titles and summaries through Anthropic, with a local fallback +- Local title/summary inference, with explicit opt-in Anthropic enhancement - Pin, archive, trash, restore, and delete forever - Tags, color labels, and full-text search across title and body - Keyboard navigation and a searchable command-style overlay @@ -30,26 +30,31 @@ Postgres, with a guest mode that keeps notes in the browser until sign-in. - Public share links with 128-bit bearer tokens, vanity links, and revocation - Google Keep Takeout, plain-text, Markdown, ZIP, and PDF import - Plain-text or ZIP export -- Public image uploads through S3-compatible storage or Vercel Blob +- Private, authenticated image uploads through S3-compatible storage or Vercel Blob - Anonymous aggregate analytics and a private owner dashboard Offline support protects edits made while the application is already open. For privacy, personalized HTML and public shared notes are never stored by the -service worker, so a full page reload still requires a network connection. +service worker, so a full page reload still requires a network connection. An +explicit sign-out removes that account's IndexedDB cache and queued mutations. ## Getting started ```bash npm install cp .env.example .env.local -# Set AUTH_SECRET and DATABASE_URL. +# Set AUTH_SECRET, AUTH_URL, and DATABASE_URL. # Add Google, Resend, Anthropic, and object-storage settings as needed. npm run dev ``` -The idempotent bootstrap in `lib/db.ts` creates the notes, users, native-auth, -audit, and analytics tables on first use. A transient bootstrap failure is -retryable on the next request. +The idempotent bootstrap in `lib/db.ts` creates the notes, users, uploads, +native-auth, audit, and analytics tables on first use. A transient bootstrap +failure is retryable on the next request. + +AI metadata is disabled by default. Setting `AI_METADATA_ENABLED=true` and an +Anthropic key sends up to the first 8 KiB of authenticated note text to +Anthropic for a title and summary. Guest text always stays in the browser. Useful checks: @@ -65,6 +70,7 @@ npm audit ```text app/ api/notes/ Authenticated CRUD, import/export, titles, sharing + api/uploads/ Owner/share-authorized private image delivery api/auth/ Auth.js plus registration and email verification note/[noteId]/ Stable note deep links p/[token]/ Public shared-note pages @@ -86,13 +92,28 @@ proxy.ts Auth gate, CSP, rewrites, and public rate limits ## Deployment Production is self-hosted behind Caddy. The GitHub Actions workflow runs the -test, typecheck, and production-build gates before deploying the latest `main` -commit. `scripts/deploy.sh` provides the equivalent manual flow and refreshes -the project screenshot afterward. +test, typecheck, audit, and production-build gates before deploying the latest +`main` commit. The deploy environment requires a pinned `DEPLOY_KNOWN_HOSTS` +entry in addition to the host and SSH key. `scripts/deploy.sh` provides the +equivalent manual flow and refreshes the project screenshot afterward. The app can also run on other Node.js hosts when the same environment variables and a Postgres database are available. +## Security and privacy + +Keep's server can read note content: notes are stored as plaintext Postgres +columns so server search, export, and optional AI metadata work. Browser guest +notes are plaintext localStorage; signed-in offline copies are plaintext +IndexedDB scoped by account. The native clients put titles, summaries, and tags +in Spotlight, while full note bodies stay out of the system index. + +Random public share links use 128-bit tokens. Vanity tokens must be at least 16 +characters and should still be treated as public URLs. Uploaded images remain +private objects and are served only to their owner or through a note that +currently has a valid share token. See [SECURITY.md](./SECURITY.md) for the +deployment checklist and reporting process. + ## License MIT — see [LICENSE](./LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4d0791c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security + +## Reporting a vulnerability + +Please use a private [GitHub security advisory](https://github.com/fjbarrett/keep/security/advisories/new). +Include the affected route or client, reproduction steps, and likely impact. Do +not put credentials, private notes, or an unpatched exploit in a public issue. + +## Deployment checklist + +- Set a strong `AUTH_SECRET` and the exact HTTPS `AUTH_URL`. +- Keep Postgres on a private network. TLS verifies system CAs by default; use + `DATABASE_CA_CERT` for a private CA. `DATABASE_TLS_INSECURE=true` is only for + loopback development with `sslmode=no-verify`. +- Keep attachment storage private. S3 deployments should enable Block Public + Access and grant the application only object read/write/delete permissions + under the `keep/` prefix. +- Set `DEPLOY_KNOWN_HOSTS` to a separately verified SSH host-key line. Deployment + fails closed when the host key changes. +- Leave `AI_METADATA_ENABLED=false` unless sending authenticated note text to + Anthropic is acceptable and disclosed to users. Provider and account budgets + should also be configured outside the application. +- Back up Postgres and object storage with encryption at rest and tested restore + procedures. Restrict production logs and database access to operators who need + them. + +## Data boundaries + +Keep is not end-to-end encrypted. The server can read note content. Signed-in +web clients keep account-scoped offline copies in IndexedDB until explicit +sign-out or site-data removal; guest notes remain in localStorage. Native +Spotlight integration indexes titles, summaries, and tags, but not note bodies. + +Public shares are bearer links. Random links carry 128 bits of entropy; vanity +links are public names with a 16-character minimum. Revocation stops page, +download, and attachment access immediately. + +## Upgrade note for public attachments + +Releases before the private-upload change wrote attachments with public-read +storage permissions and embedded their provider URLs in notes. New uploads are +private and use `/api/uploads/`. Operators upgrading an existing deployment +should inventory old `keep/` objects, remove public ACLs after deciding how to +handle legacy note links, and delete unreferenced public objects. diff --git a/__tests__/appUrl.test.ts b/__tests__/appUrl.test.ts new file mode 100644 index 0000000..93966c4 --- /dev/null +++ b/__tests__/appUrl.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { appOrigin, isSameOriginMutation } from "@/lib/appUrl"; + +afterEach(() => { + delete process.env.AUTH_URL; +}); + +describe("canonical application origin", () => { + it("uses configured AUTH_URL instead of a request-controlled host", () => { + process.env.AUTH_URL = "https://keeptxt.com/some/path"; + expect(appOrigin(new Request("https://attacker.invalid/register"))) + .toBe("https://keeptxt.com"); + }); + + it("rejects same-site requests from another origin", () => { + process.env.AUTH_URL = "https://keeptxt.com"; + const request = new Request("https://keeptxt.com/api/auth/register", { + method: "POST", + headers: { + origin: "https://untrusted.keeptxt.com", + "sec-fetch-site": "same-site", + }, + }); + expect(isSameOriginMutation(request)).toBe(false); + }); +}); diff --git a/__tests__/dbCaCert.test.ts b/__tests__/dbCaCert.test.ts index 0541dee..5c44f62 100644 --- a/__tests__/dbCaCert.test.ts +++ b/__tests__/dbCaCert.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { mkdtempSync, writeFileSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { caCert } from "@/lib/db"; +import { caCert, databaseSslOptions } from "@/lib/db"; const PEM = "-----BEGIN CERTIFICATE-----\nMIIabc123\n-----END CERTIFICATE-----\n"; @@ -10,6 +10,32 @@ afterEach(() => { delete process.env.DATABASE_CA_CERT; }); +describe("databaseSslOptions", () => { + it("verifies certificates by default for TLS connections", () => { + expect(databaseSslOptions("require")).toEqual({ rejectUnauthorized: true }); + expect(databaseSslOptions("verify-full")).toEqual({ rejectUnauthorized: true }); + }); + + it("uses a configured private CA while retaining verification", () => { + expect(databaseSslOptions("require", PEM)).toEqual({ + ca: PEM, + rejectUnauthorized: true, + }); + }); + + it("requires an explicit flag before disabling verification", () => { + expect(() => databaseSslOptions("no-verify")).toThrow(/DATABASE_TLS_INSECURE/); + expect(databaseSslOptions("no-verify", undefined, true)).toEqual({ + rejectUnauthorized: false, + }); + }); + + it("does not enable TLS when sslmode is absent or disabled", () => { + expect(databaseSslOptions(null)).toBeUndefined(); + expect(databaseSslOptions("disable")).toBeUndefined(); + }); +}); + describe("caCert", () => { it("returns undefined when DATABASE_CA_CERT is unset", () => { expect(caCert()).toBeUndefined(); diff --git a/__tests__/imageUpload.test.ts b/__tests__/imageUpload.test.ts new file mode 100644 index 0000000..a355856 --- /dev/null +++ b/__tests__/imageUpload.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { hasValidImageSignature, imageExtension } from "@/lib/imageUpload"; + +describe("image upload validation", () => { + it("recognizes allowed raster signatures", () => { + expect(hasValidImageSignature( + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + "image/png", + )).toBe(true); + expect(hasValidImageSignature( + new TextEncoder().encode("GIF89a"), + "image/gif", + )).toBe(true); + expect(imageExtension("image/jpeg")).toBe("jpg"); + }); + + it("rejects HTML mislabeled as an image", () => { + expect(hasValidImageSignature( + new TextEncoder().encode(""), + "image/png", + )).toBe(false); + }); +}); diff --git a/__tests__/nativeExchangeRoute.test.ts b/__tests__/nativeExchangeRoute.test.ts index 05af70c..93a81bf 100644 --- a/__tests__/nativeExchangeRoute.test.ts +++ b/__tests__/nativeExchangeRoute.test.ts @@ -29,4 +29,9 @@ describe("/api/native/exchange", () => { const res = await exchange("not json"); expect(res.status).toBe(400); }); + + it("rejects oversized bodies before database work", async () => { + const res = await exchange(JSON.stringify({ code: "x".repeat(3000) })); + expect(res.status).toBe(413); + }); }); diff --git a/__tests__/noteCreateIdempotency.test.ts b/__tests__/noteCreateIdempotency.test.ts index c067919..53f70b1 100644 --- a/__tests__/noteCreateIdempotency.test.ts +++ b/__tests__/noteCreateIdempotency.test.ts @@ -40,6 +40,7 @@ beforeEach(() => { describe("POST /api/notes idempotency", () => { it("returns an existing owned note when an offline create is replayed", async () => { mocks.query + .mockResolvedValueOnce({ rows: [{ count: "0" }] }) .mockResolvedValueOnce({ rows: [] }) .mockResolvedValueOnce({ rows: [row] }); @@ -53,6 +54,6 @@ describe("POST /api/notes idempotency", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ note: row }); - expect(mocks.query).toHaveBeenCalledTimes(2); + expect(mocks.query).toHaveBeenCalledTimes(3); }); }); diff --git a/__tests__/offlineDb.test.ts b/__tests__/offlineDb.test.ts index aa68aa2..4a7b3f8 100644 --- a/__tests__/offlineDb.test.ts +++ b/__tests__/offlineDb.test.ts @@ -1,8 +1,10 @@ import "fake-indexeddb/auto"; import { describe, expect, it } from "vitest"; +import { openDB } from "idb"; import { addPendingOp, cacheNotes, + clearOwnerData, getCachedNotes, getPendingOps, removePendingOp, @@ -29,6 +31,25 @@ function note(id: string, body: string): Note { } describe("account-scoped offline storage", () => { + it("discards ownerless legacy pending creates instead of assigning them to an account", async () => { + const legacy = await openDB("keep-offline", 1, { + upgrade(db) { + db.createObjectStore("notes", { keyPath: "id" }); + db.createObjectStore("pending", { keyPath: "id" }); + }, + }); + await legacy.put("pending", { + id: "legacy-create", + type: "create", + noteId: "legacy-note", + payload: note("legacy-note", "another account's private draft"), + createdAt: 1, + }); + legacy.close(); + + await expect(getPendingOps(`new-owner-${crypto.randomUUID()}`)).resolves.toEqual([]); + }); + it("never returns one account's cached notes to another account", async () => { const firstOwner = `first-${crypto.randomUUID()}`; const secondOwner = `second-${crypto.randomUUID()}`; @@ -55,4 +76,18 @@ describe("account-scoped offline storage", () => { await removePendingOp(owner, first.id); await expect(getPendingOps(owner)).resolves.toEqual([second]); }); + + it("removes cached notes and pending edits on explicit sign-out", async () => { + const owner = `signout-${crypto.randomUUID()}`; + await cacheNotes(owner, [note("private", "local copy")]); + await addPendingOp(owner, { + type: "update", + noteId: "private", + payload: { body: "queued private edit" }, + }); + + await clearOwnerData(owner); + await expect(getCachedNotes(owner)).resolves.toEqual([]); + await expect(getPendingOps(owner)).resolves.toEqual([]); + }); }); diff --git a/__tests__/password.test.ts b/__tests__/password.test.ts index f3aa8e7..38d9511 100644 --- a/__tests__/password.test.ts +++ b/__tests__/password.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { passwordIssue, hashPassword, verifyPassword } from "@/lib/password"; +import { + passwordIssue, + passwordNeedsRehash, + hashPassword, + verifyPassword, +} from "@/lib/password"; describe("passwordIssue", () => { it("rejects short passwords", () => { @@ -23,7 +28,13 @@ describe("hashPassword / verifyPassword", () => { it("round-trips a password and rejects a wrong one", async () => { const stored = await hashPassword("correct horse battery staple"); expect(stored.startsWith("scrypt$")).toBe(true); + expect(passwordNeedsRehash(stored)).toBe(false); expect(await verifyPassword("correct horse battery staple", stored)).toBe(true); expect(await verifyPassword("wrong password entirely", stored)).toBe(false); }); + + it("marks the previous work factor for upgrade", () => { + expect(passwordNeedsRehash("scrypt$16384$8$1$c2FsdHNhbHRzYWx0c2FsdA==$aGFzaA==")) + .toBe(true); + }); }); diff --git a/__tests__/requestBody.test.ts b/__tests__/requestBody.test.ts new file mode 100644 index 0000000..e68be06 --- /dev/null +++ b/__tests__/requestBody.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { + readFormDataBody, + readJsonBody, + RequestBodyTooLarge, +} from "@/lib/requestBody"; + +describe("bounded request bodies", () => { + it("rejects an oversized body even without Content-Length", async () => { + const request = new Request("https://keeptxt.com/api/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: "x".repeat(128) }), + }); + expect(request.headers.get("content-length")).toBeNull(); + await expect(readJsonBody(request, 32)).rejects.toBeInstanceOf(RequestBodyTooLarge); + }); + + it("parses JSON within the limit", async () => { + const request = new Request("https://keeptxt.com/api/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true }), + }); + await expect(readJsonBody(request, 128)).resolves.toEqual({ ok: true }); + }); + + it("rejects simple cross-origin content types for JSON routes", async () => { + const request = new Request("https://keeptxt.com/api/test", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: JSON.stringify({ ok: true }), + }); + await expect(readJsonBody(request, 128)).rejects.toThrow("Invalid request body"); + }); + + it("parses multipart data within the limit", async () => { + const boundary = "keep-test-boundary"; + const body = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="note.txt"', + "Content-Type: text/plain", + "", + "hello", + `--${boundary}--`, + "", + ].join("\r\n"); + const request = new Request("https://keeptxt.com/api/test", { + method: "POST", + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + body, + }); + const parsed = await readFormDataBody(request, 4096); + expect((parsed.get("file") as File).name).toBe("note.txt"); + }); +}); diff --git a/__tests__/titleModelSecurity.test.ts b/__tests__/titleModelSecurity.test.ts new file mode 100644 index 0000000..a1d5b80 --- /dev/null +++ b/__tests__/titleModelSecurity.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { generateNoteMeta } from "@/lib/titleModel"; + +afterEach(() => { + delete process.env.AI_METADATA_ENABLED; + delete process.env.ANTHROPIC_KEY; + vi.unstubAllGlobals(); +}); + +describe("AI metadata privacy gate", () => { + it("does not send note text when explicit opt-in is absent", async () => { + process.env.ANTHROPIC_KEY = "test-key"; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(generateNoteMeta("private note body")).resolves.toMatchObject({ + title: "private note body", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("caps provider text at 8 KiB when enabled", async () => { + process.env.AI_METADATA_ENABLED = "true"; + process.env.ANTHROPIC_KEY = "test-key"; + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + content: [{ text: '"title":"Generated","summary":"Short"}' }], + }), { status: 200, headers: { "content-type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + + await generateNoteMeta("a".repeat(20 * 1024)); + const request = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(request.messages[0].content).toHaveLength(8 * 1024); + }); +}); diff --git a/__tests__/titleRoute.test.ts b/__tests__/titleRoute.test.ts index 182bf99..341483c 100644 --- a/__tests__/titleRoute.test.ts +++ b/__tests__/titleRoute.test.ts @@ -1,7 +1,25 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authMock = vi.hoisted(() => vi.fn()); +vi.mock("@/auth", () => ({ auth: authMock })); + import { POST } from "@/app/api/notes/title/route"; +beforeEach(() => { + authMock.mockResolvedValue({ user: { id: `owner-${crypto.randomUUID()}` } }); +}); + describe("/api/notes/title", () => { + it("requires authentication before accepting note text", async () => { + authMock.mockResolvedValueOnce(null); + const res = await POST(new Request("https://keeptxt.com/api/notes/title", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ body: "private text" }), + })); + expect(res.status).toBe(401); + }); + it("rejects oversized note bodies before title generation", async () => { const res = await POST( new Request("https://keeptxt.com/api/notes/title", { @@ -40,4 +58,17 @@ describe("/api/notes/title", () => { expect(res.status).toBe(429); expect(res.headers.get("Retry-After")).toBeTruthy(); }); + + it("rejects a chunked oversized body without relying on Content-Length", async () => { + const body = JSON.stringify({ body: "a".repeat(20 * 1024) }); + const res = await POST(new Request("https://keeptxt.com/api/notes/title", { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-for": `203.0.113.${Math.floor(Math.random() * 100) + 100}`, + }, + body, + })); + expect(res.status).toBe(413); + }); }); diff --git a/__tests__/uploadReadRoute.test.ts b/__tests__/uploadReadRoute.test.ts new file mode 100644 index 0000000..c4e35fb --- /dev/null +++ b/__tests__/uploadReadRoute.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + query: vi.fn(), + getPrivateFile: vi.fn(), +})); + +vi.mock("@/auth", () => ({ auth: mocks.auth })); +vi.mock("@/lib/db", () => ({ + ready: vi.fn(async () => {}), + pool: () => ({ query: mocks.query }), +})); +vi.mock("@/lib/storage", () => ({ + getPrivateFile: mocks.getPrivateFile, + deletePrivateFile: vi.fn(), +})); + +import { GET } from "@/app/api/uploads/[id]/route"; + +const id = "a".repeat(32); +const upload = { + user_id: "owner", + storage_key: `keep/owner/${id}.png`, + content_type: "image/png", +}; + +function request(query = "") { + return GET( + new Request(`https://keeptxt.com/api/uploads/${id}${query}`), + { params: Promise.resolve({ id }) }, + ); +} + +beforeEach(() => { + mocks.auth.mockReset(); + mocks.query.mockReset(); + mocks.getPrivateFile.mockReset(); + mocks.query.mockResolvedValueOnce({ rows: [upload] }); + mocks.getPrivateFile.mockResolvedValue({ + body: new Uint8Array([1, 2, 3]).buffer, + contentType: "image/png", + }); +}); + +describe("GET /api/uploads/:id", () => { + it("serves a private upload to its owner", async () => { + mocks.auth.mockResolvedValue({ user: { id: "owner" } }); + const response = await request(); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(mocks.getPrivateFile).toHaveBeenCalledWith(upload.storage_key); + }); + + it("hides an upload from an unauthenticated caller", async () => { + mocks.auth.mockResolvedValue(null); + const response = await request(); + expect(response.status).toBe(404); + expect(mocks.getPrivateFile).not.toHaveBeenCalled(); + }); + + it("serves only uploads referenced by the shared note", async () => { + mocks.auth.mockResolvedValue(null); + mocks.query.mockResolvedValueOnce({ rows: [{ allowed: 1 }] }); + const response = await request("?share=public-share-token"); + expect(response.status).toBe(200); + expect(mocks.query).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts index 2f323b3..4681651 100644 --- a/app/api/analytics/route.ts +++ b/app/api/analytics/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { clientIpFromHeaders, createTokenBucketRateLimiter } from "@/lib/rateLimit"; import { enforceIpRateLimit } from "@/lib/rateLimitGuard"; import { recordAnalyticsEvent } from "@/lib/analytics"; +import { readJsonBody, requestBodyError } from "@/lib/requestBody"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -14,6 +15,7 @@ const analyticsRateLimit = createTokenBucketRateLimiter({ }); const noContent = () => new NextResponse(null, { status: 204 }); +const MAX_ANALYTICS_BODY = 4 * 1024; export async function POST(req: Request) { const limited = enforceIpRateLimit( @@ -24,15 +26,23 @@ export async function POST(req: Request) { ); if (limited) return limited; - const body = await req.json().catch(() => null); - const path = typeof body?.path === "string" ? body.path : ""; + let body: unknown; + try { + body = await readJsonBody(req, MAX_ANALYTICS_BODY); + } catch (err) { + const tooLarge = requestBodyError(err); + if (tooLarge) return tooLarge; + body = null; + } + const input = body && typeof body === "object" ? body as Record : null; + const path = typeof input?.path === "string" ? input.path : ""; // Malformed beacons are ignored silently — never surface analytics to clients. if (!path) return noContent(); await recordAnalyticsEvent({ type: "pageview", path, - referrer: typeof body?.referrer === "string" ? body.referrer : null, + referrer: typeof input?.referrer === "string" ? input.referrer : null, ip: clientIpFromHeaders(req.headers), ua: req.headers.get("user-agent") ?? "", selfHost: req.headers.get("host") ?? undefined, diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 999cfbc..0eaffb9 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -7,6 +7,8 @@ import { createTokenBucketRateLimiter } from "@/lib/rateLimit"; import { enforceIpRateLimit } from "@/lib/rateLimitGuard"; import { logger, maskEmail } from "@/lib/logger"; import { recordSecurityEvent } from "@/lib/audit"; +import { readJsonBody, requestBodyError } from "@/lib/requestBody"; +import { appOrigin, isSameOriginMutation } from "@/lib/appUrl"; export const runtime = "nodejs"; @@ -20,8 +22,12 @@ const registerRateLimit = createTokenBucketRateLimiter({ limit: 6, windowMs: 60_000, }); +const MAX_REGISTER_BODY = 4 * 1024; export async function POST(req: Request) { + if (!isSameOriginMutation(req)) { + return NextResponse.json({ error: "Cross-origin request blocked" }, { status: 403 }); + } const limited = enforceIpRateLimit( registerRateLimit, req.headers, @@ -29,10 +35,19 @@ export async function POST(req: Request) { "Too many attempts. Try again shortly.", ); if (limited) return limited; + const origin = appOrigin(req); - const body = await req.json().catch(() => null); - const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : ""; - const password = typeof body?.password === "string" ? body.password : ""; + let body: unknown; + try { + body = await readJsonBody(req, MAX_REGISTER_BODY); + } catch (err) { + const tooLarge = requestBodyError(err); + if (tooLarge) return tooLarge; + body = null; + } + const input = body && typeof body === "object" ? body as Record : null; + const email = typeof input?.email === "string" ? input.email.trim().toLowerCase() : ""; + const password = typeof input?.password === "string" ? input.password : ""; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { return NextResponse.json({ error: "Enter a valid email." }, { status: 400 }); @@ -71,7 +86,6 @@ export async function POST(req: Request) { meta: { email: maskEmail(email) }, }); - const origin = process.env.AUTH_URL ?? new URL(req.url).origin; const verifyUrl = `${origin.replace(/\/$/, "")}/api/auth/verify?token=${token}`; try { await sendVerificationEmail(email, verifyUrl); diff --git a/app/api/auth/resend/route.ts b/app/api/auth/resend/route.ts index f142d15..79af2ba 100644 --- a/app/api/auth/resend/route.ts +++ b/app/api/auth/resend/route.ts @@ -5,14 +5,20 @@ import { sendVerificationEmail } from "@/lib/email"; import { logger, maskEmail } from "@/lib/logger"; import { createTokenBucketRateLimiter } from "@/lib/rateLimit"; import { enforceIpRateLimit } from "@/lib/rateLimitGuard"; +import { readJsonBody, requestBodyError } from "@/lib/requestBody"; +import { appOrigin, isSameOriginMutation } from "@/lib/appUrl"; export const runtime = "nodejs"; const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; const resendRateLimit = createTokenBucketRateLimiter({ limit: 6, windowMs: 60_000 }); const response = () => NextResponse.json({ ok: true }); +const MAX_RESEND_BODY = 2 * 1024; export async function POST(req: Request) { + if (!isSameOriginMutation(req)) { + return NextResponse.json({ error: "Cross-origin request blocked" }, { status: 403 }); + } const limited = enforceIpRateLimit( resendRateLimit, req.headers, @@ -20,9 +26,18 @@ export async function POST(req: Request) { "Too many attempts. Try again shortly.", ); if (limited) return limited; + const origin = appOrigin(req); - const body = await req.json().catch(() => null); - const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : ""; + let body: unknown; + try { + body = await readJsonBody(req, MAX_RESEND_BODY); + } catch (err) { + const tooLarge = requestBodyError(err); + if (tooLarge) return tooLarge; + body = null; + } + const input = body && typeof body === "object" ? body as Record : null; + const email = typeof input?.email === "string" ? input.email.trim().toLowerCase() : ""; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return response(); await ready(); @@ -37,7 +52,6 @@ export async function POST(req: Request) { ); if (!rows[0]) return response(); - const origin = process.env.AUTH_URL ?? new URL(req.url).origin; const verifyUrl = `${origin.replace(/\/$/, "")}/api/auth/verify?token=${token}`; try { await sendVerificationEmail(email, verifyUrl); diff --git a/app/api/auth/revoke/route.ts b/app/api/auth/revoke/route.ts new file mode 100644 index 0000000..5d8be70 --- /dev/null +++ b/app/api/auth/revoke/route.ts @@ -0,0 +1,30 @@ +import { auth } from "@/auth"; +import { pool, ready } from "@/lib/db"; +import { internalError } from "@/lib/apiError"; +import { recordSecurityEvent } from "@/lib/audit"; +import { isSameOriginMutation } from "@/lib/appUrl"; + +export const runtime = "nodejs"; + +export async function POST(req: Request) { + if (!isSameOriginMutation(req)) { + return Response.json({ error: "Cross-origin request blocked" }, { status: 403 }); + } + const session = await auth(); + if (!session?.user?.id) return Response.json({ error: "Unauthorized" }, { status: 401 }); + try { + await ready(); + await pool().query( + `UPDATE users SET session_version = session_version + 1, updated_at = $1 WHERE id = $2`, + [Date.now(), session.user.id], + ); + void recordSecurityEvent("session.revoke", { + userId: session.user.id, + headers: req.headers, + meta: { scope: "all" }, + }); + return Response.json({ ok: true }); + } catch (err) { + return internalError("auth:revoke", err); + } +} diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index bc905b6..1e12be0 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { pool, ready } from "@/lib/db"; import { createTokenBucketRateLimiter } from "@/lib/rateLimit"; import { enforceIpRateLimit } from "@/lib/rateLimitGuard"; +import { appOrigin } from "@/lib/appUrl"; export const runtime = "nodejs"; @@ -21,7 +22,7 @@ export async function GET(req: Request) { ); if (limited) return limited; - const base = (process.env.AUTH_URL ?? new URL(req.url).origin).replace(/\/$/, ""); + const base = appOrigin(req); const token = new URL(req.url).searchParams.get("token"); if (!token) { return NextResponse.redirect(`${base}/signin?error=verify`); diff --git a/app/api/native/exchange/route.ts b/app/api/native/exchange/route.ts index 594524b..ecf4155 100644 --- a/app/api/native/exchange/route.ts +++ b/app/api/native/exchange/route.ts @@ -1,23 +1,33 @@ import { NextResponse } from "next/server"; import { pool, ready } from "@/lib/db"; +import { readJsonBody, requestBodyError } from "@/lib/requestBody"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -// Matches NextAuth's default session lifetime so the cookie persists in the +// Matches Keep's seven-day session lifetime so the cookie persists in the // app's cookie store across launches (a bare session cookie would be dropped on // quit). The JWT's own exp still governs validity — an expired token 401s and // the app re-runs sign-in. -const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60; +const SESSION_COOKIE_MAX_AGE = 7 * 24 * 60 * 60; +const MAX_EXCHANGE_BODY = 2 * 1024; // Trades the one-time code minted by /native/bridge for the session cookie, so // the native app's URLSession becomes authenticated. Public (it runs before a // session exists — see the allowlist in proxy.ts); the high-entropy, // single-use code is the credential, so there is nothing to leak without it. export async function POST(req: Request) { - const body = await req.json().catch(() => null); - const code = typeof body?.code === "string" ? body.code : ""; - if (!code) { + let body: unknown; + try { + body = await readJsonBody(req, MAX_EXCHANGE_BODY); + } catch (err) { + const tooLarge = requestBodyError(err); + if (tooLarge) return tooLarge; + body = null; + } + const input = body && typeof body === "object" ? body as Record : null; + const code = typeof input?.code === "string" ? input.code : ""; + if (!/^[A-Za-z0-9_-]{43}$/.test(code)) { return NextResponse.json({ error: "Missing code." }, { status: 400 }); } diff --git a/app/api/notes/[id]/route.ts b/app/api/notes/[id]/route.ts index 805d74f..17424bb 100644 --- a/app/api/notes/[id]/route.ts +++ b/app/api/notes/[id]/route.ts @@ -9,6 +9,8 @@ import { MAX_NOTE_TITLE, tagsInvalid, } from "@/lib/noteLimits"; +import { readJsonBody, requestBodyError } from "@/lib/requestBody"; +import { deletePrivateFile } from "@/lib/storage"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -25,6 +27,7 @@ const ALLOWED = new Set([ "highlight", "tags", ]); +const MAX_NOTE_REQUEST_BYTES = MAX_NOTE_BODY + 16 * 1024; export async function PATCH( req: Request, @@ -37,7 +40,11 @@ export async function PATCH( } try { await ready(); - const patch = await req.json(); + const parsed = await readJsonBody(req, MAX_NOTE_REQUEST_BYTES); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return NextResponse.json({ error: "Invalid note update." }, { status: 400 }); + } + const patch = parsed as Record; // Validate the few client-controlled fields before they reach SQL. if ("color" in patch && patch.color !== null && !isNoteColor(patch.color)) { @@ -107,6 +114,8 @@ export async function PATCH( } return NextResponse.json({ note: rowToNote(rows[0]) }); } catch (err) { + const tooLarge = requestBodyError(err, "Note is too large."); + if (tooLarge) return tooLarge; return internalError("notes:item", err); } } @@ -122,10 +131,29 @@ export async function DELETE( } try { await ready(); - await pool().query( - `DELETE FROM notes WHERE id = $1 AND user_id = $2`, + const { rows } = await pool().query<{ body: string }>( + `DELETE FROM notes WHERE id = $1 AND user_id = $2 RETURNING body`, [id, session.user.id], ); + const uploadIds = new Set( + [...(rows[0]?.body ?? "").matchAll(/\/api\/uploads\/([0-9a-f]{32})/g)] + .map((match) => match[1]), + ); + for (const uploadId of uploadIds) { + const reference = `/api/uploads/${uploadId}`; + const stillUsed = await pool().query( + `SELECT 1 FROM notes WHERE user_id = $1 AND position($2 in body) > 0 LIMIT 1`, + [session.user.id, reference], + ); + if (stillUsed.rows[0]) continue; + const removed = await pool().query<{ storage_key: string }>( + `DELETE FROM uploads WHERE id = $1 AND user_id = $2 RETURNING storage_key`, + [uploadId, session.user.id], + ); + if (removed.rows[0]) { + await deletePrivateFile(removed.rows[0].storage_key).catch(() => {}); + } + } return NextResponse.json({ ok: true }); } catch (err) { return internalError("notes:item", err); diff --git a/app/api/notes/[id]/share/route.ts b/app/api/notes/[id]/share/route.ts index 7493ea5..b2d0fe6 100644 --- a/app/api/notes/[id]/share/route.ts +++ b/app/api/notes/[id]/share/route.ts @@ -4,9 +4,11 @@ import { pool, ready, rowToNote, NoteRow } from "@/lib/db"; import { internalError, isUniqueViolation } from "@/lib/apiError"; import { recordSecurityEvent } from "@/lib/audit"; import { newShareToken } from "@/lib/shareToken"; +import { readJsonBody, requestBodyError } from "@/lib/requestBody"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +const MAX_SHARE_BODY = 2 * 1024; export async function POST( req: Request, @@ -83,11 +85,19 @@ export async function PUT( if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const body = await req.json().catch(() => null); - const token = typeof body?.token === "string" ? body.token.trim() : ""; - if (!/^[A-Za-z0-9_-]{3,40}$/.test(token)) { + let body: unknown; + try { + body = await readJsonBody(req, MAX_SHARE_BODY); + } catch (err) { + const tooLarge = requestBodyError(err); + if (tooLarge) return tooLarge; + body = null; + } + const input = body && typeof body === "object" ? body as Record : null; + const token = typeof input?.token === "string" ? input.token.trim() : ""; + if (!/^[A-Za-z0-9_-]{16,40}$/.test(token)) { return NextResponse.json( - { error: "Use 3–40 letters, numbers, dashes or underscores." }, + { error: "Use 16–40 letters, numbers, dashes or underscores." }, { status: 400 }, ); } diff --git a/app/api/notes/import/route.ts b/app/api/notes/import/route.ts index 08346b1..52c9f77 100644 --- a/app/api/notes/import/route.ts +++ b/app/api/notes/import/route.ts @@ -5,6 +5,8 @@ import { KeepImportNote, parseGoogleKeepImport } from "@/lib/googleKeepImport"; import { pool, ready } from "@/lib/db"; import { heuristicNoteMeta } from "@/lib/titleModel"; import { internalError } from "@/lib/apiError"; +import { readFormDataBody, requestBodyError } from "@/lib/requestBody"; +import { MAX_NOTES_PER_USER } from "@/lib/noteLimits"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -12,6 +14,7 @@ export const dynamic = "force-dynamic"; // Bound the raw upload before we even read it into memory; the parser then caps // note count and decompressed size to defend against zip bombs. const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; +const MAX_MULTIPART_BYTES = MAX_UPLOAD_BYTES + 256 * 1024; // Deterministic 128-bit id so re-importing the same note dedupes via ON CONFLICT. function googleKeepImportId(userId: string, note: KeepImportNote) { @@ -33,7 +36,7 @@ export async function POST(req: Request) { } try { - const form = await req.formData(); + const form = await readFormDataBody(req, MAX_MULTIPART_BYTES); const file = form.get("file"); if (!(file instanceof File)) { return NextResponse.json({ error: "Upload a Takeout ZIP or Keep JSON file" }, { status: 400 }); @@ -52,8 +55,14 @@ export async function POST(req: Request) { const importable = notes.filter((note) => !note.trashed); await ready(); + const usage = await pool().query<{ count: string }>( + `SELECT count(*)::text AS count FROM notes WHERE user_id = $1`, + [session.user.id], + ); + const available = Math.max(0, MAX_NOTES_PER_USER - Number(usage.rows[0]?.count ?? 0)); + const withinQuota = importable.slice(0, available); let imported = 0; - for (const note of importable) { + for (const note of withinQuota) { // Heuristic title/summary only — a per-note model call would turn one // upload into N billed Anthropic requests. const meta = heuristicNoteMeta(note.body); @@ -79,10 +88,12 @@ export async function POST(req: Request) { return NextResponse.json({ imported, skipped: skipped + notes.length - importable.length, - duplicates: importable.length - imported, - truncated, + duplicates: withinQuota.length - imported, + truncated: truncated || withinQuota.length < importable.length, }); } catch (err) { + const tooLarge = requestBodyError(err, "That file is too large to import (max 20 MB)."); + if (tooLarge) return tooLarge; return internalError("notes:import", err); } } diff --git a/app/api/notes/route.ts b/app/api/notes/route.ts index 26bd053..55a0bd1 100644 --- a/app/api/notes/route.ts +++ b/app/api/notes/route.ts @@ -2,20 +2,23 @@ import { NextResponse } from "next/server"; import { createHash } from "crypto"; import { auth } from "@/auth"; import { newId, pool, ready, rowToNote, NoteRow } from "@/lib/db"; -import { generateNoteMeta } from "@/lib/titleModel"; +import { heuristicNoteMeta } from "@/lib/titleModel"; import { internalError } from "@/lib/apiError"; import { MAX_NOTE_BODY, MAX_NOTE_SUMMARY, MAX_NOTE_TITLE, + MAX_NOTES_PER_USER, tagsInvalid, } from "@/lib/noteLimits"; import { isNoteColor } from "@/lib/noteColors"; +import { readJsonBody, requestBodyError } from "@/lib/requestBody"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const CLIENT_NOTE_KEY = /^[A-Za-z0-9_-]{1,64}$/; +const MAX_NOTE_REQUEST_BYTES = MAX_NOTE_BODY + 16 * 1024; function noteIdForCreate(userId: string, requested: unknown) { if (typeof requested !== "string") return newId(); @@ -54,7 +57,11 @@ export async function POST(req: Request) { } try { await ready(); - const body = await req.json(); + const parsed = await readJsonBody(req, MAX_NOTE_REQUEST_BYTES); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return NextResponse.json({ error: "Invalid note." }, { status: 400 }); + } + const body = parsed as Record; const noteBody = String(body.body ?? ""); if (noteBody.length > MAX_NOTE_BODY) { return NextResponse.json({ error: "Note is too large." }, { status: 413 }); @@ -74,13 +81,28 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Note metadata is too large." }, { status: 400 }); } // The client normally supplies both (one Haiku call); only fall back to - // generating here when it didn't. + // local metadata here when it didn't. Model calls stay behind the dedicated + // per-account quota on /api/notes/title. if (!title) { - const meta = await generateNoteMeta(noteBody); + const meta = heuristicNoteMeta(noteBody); title = meta.title; summary = summary ?? meta.summary; } const id = noteIdForCreate(session.user.id, body.id); + const usage = await pool().query<{ count: string }>( + `SELECT count(*)::text AS count FROM notes WHERE user_id = $1`, + [session.user.id], + ); + if (Number(usage.rows[0]?.count ?? 0) >= MAX_NOTES_PER_USER) { + const existing = await pool().query( + `SELECT * FROM notes WHERE id = $1 AND user_id = $2`, + [id, session.user.id], + ); + if (existing.rows[0]) { + return NextResponse.json({ note: rowToNote(existing.rows[0]) }); + } + return NextResponse.json({ error: "Note storage quota exceeded." }, { status: 413 }); + } const now = Date.now(); const tags = Array.isArray(body.tags) ? body.tags.map(String) : []; const { rows } = await pool().query( @@ -116,6 +138,8 @@ export async function POST(req: Request) { } return NextResponse.json({ note: rowToNote(existing.rows[0]) }); } catch (err) { + const tooLarge = requestBodyError(err, "Note is too large."); + if (tooLarge) return tooLarge; return internalError("notes:list-create", err); } } diff --git a/app/api/notes/title/route.ts b/app/api/notes/title/route.ts index ec22c7f..44f750a 100644 --- a/app/api/notes/title/route.ts +++ b/app/api/notes/title/route.ts @@ -3,6 +3,9 @@ import { createTokenBucketRateLimiter } from "@/lib/rateLimit"; import { enforceIpRateLimit } from "@/lib/rateLimitGuard"; import { generateNoteMeta } from "@/lib/titleModel"; import { internalError } from "@/lib/apiError"; +import { auth } from "@/auth"; +import { readJsonBody, requestBodyError } from "@/lib/requestBody"; +import { rateLimitResponse } from "@/lib/rateLimitGuard"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -13,8 +16,21 @@ const titleRateLimit = createTokenBucketRateLimiter({ limit: 12, windowMs: 60_000, }); +const titleAccountRateLimit = createTokenBucketRateLimiter({ + limit: 30, + windowMs: 60 * 60_000, +}); +const titleGlobalRateLimit = createTokenBucketRateLimiter({ + limit: 300, + windowMs: 60 * 60_000, + maxEntries: 1, +}); export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } const limited = enforceIpRateLimit( titleRateLimit, req.headers, @@ -22,18 +38,19 @@ export async function POST(req: Request) { "Too many title requests. Try again shortly.", ); if (limited) return limited; - - const contentLength = Number(req.headers.get("content-length") ?? 0); - if (Number.isFinite(contentLength) && contentLength > MAX_TITLE_REQUEST_BYTES) { - return NextResponse.json( - { error: "Note body is too large for title generation." }, - { status: 413 }, - ); + const accountDecision = titleAccountRateLimit(`notes-title:${session.user.id}`); + if (!accountDecision.allowed) { + return rateLimitResponse(accountDecision, "Title generation quota exceeded. Try again later."); + } + const globalDecision = titleGlobalRateLimit("notes-title:global"); + if (!globalDecision.allowed) { + return rateLimitResponse(globalDecision, "Title generation is temporarily unavailable."); } try { - const body = await req.json(); - const noteBody = String(body.body ?? ""); + const body = await readJsonBody(req, MAX_TITLE_REQUEST_BYTES); + const input = body && typeof body === "object" ? body as Record : null; + const noteBody = String(input?.body ?? ""); if (noteBody.length > MAX_TITLE_BODY_CHARS) { return NextResponse.json( { error: "Note body is too large for title generation." }, @@ -42,6 +59,8 @@ export async function POST(req: Request) { } return NextResponse.json(await generateNoteMeta(noteBody)); } catch (err) { + const tooLarge = requestBodyError(err, "Note body is too large for title generation."); + if (tooLarge) return tooLarge; return internalError("notes:title", err); } } diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 0c8e456..3d9a988 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,8 +1,17 @@ import { NextResponse } from "next/server"; import { auth } from "@/auth"; -import { putPublicFile, storageConfigured } from "@/lib/storage"; +import { newId, pool, ready } from "@/lib/db"; +import { deletePrivateFile, putPrivateFile, storageConfigured } from "@/lib/storage"; +import { hasValidImageSignature, imageExtension, isAllowedImageType } from "@/lib/imageUpload"; +import { internalError } from "@/lib/apiError"; +import { readFormDataBody, requestBodyError } from "@/lib/requestBody"; export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_FILE_BYTES = 4 * 1024 * 1024; +const MAX_MULTIPART_BYTES = MAX_FILE_BYTES + 256 * 1024; +const MAX_USER_UPLOAD_BYTES = 100 * 1024 * 1024; export async function POST(req: Request) { const session = await auth(); @@ -14,36 +23,65 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Image uploads not configured" }, { status: 501 }); } - const form = await req.formData(); - const file = form.get("file") as File | null; - if (!file) { - return NextResponse.json({ error: "No file" }, { status: 400 }); - } - - const maxSize = 4 * 1024 * 1024; // 4 MB - if (file.size > maxSize) { - return NextResponse.json({ error: "File too large (max 4 MB)" }, { status: 400 }); - } - - // Raster formats only. SVG is deliberately excluded: it can carry inline - //