diff --git a/.env.example b/.env.example index 0338eca..76bee4e 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,13 @@ -# Postgres connection string used by lib/db.ts. -# TLS certificates are verified against the system trust store by default. -# Managed providers normally work with sslmode=require as written below. +# Postgres connection string used by lib/db.ts. sslmode follows libpq semantics: +# require/prefer encrypt without verifying the server certificate (fine for a +# self-signed Postgres on the same host); use verify-full when the database is +# on another host to verify against the system trust store or DATABASE_CA_CERT. 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 against that CA. When unset, system CAs apply. +# connection always verifies the server against that CA. When unset, +# verification is governed by sslmode. DATABASE_CA_CERT= # Local development only. Set this alongside sslmode=no-verify when a loopback @@ -25,8 +27,9 @@ 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 +# A configured key sends note text to Anthropic for titles/summaries. Set to +# false to keep note text local while leaving the key in place. +AI_METADATA_ENABLED= # Override the model (default: claude-haiku-4-5-20251001). ANTHROPIC_MODEL= diff --git a/SECURITY.md b/SECURITY.md index 4d0791c..132c013 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,16 +9,19 @@ 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 +- Keep Postgres on a private network. `sslmode=require` encrypts but does not + authenticate the server — acceptable for a same-host database; use + `sslmode=verify-full` (with `DATABASE_CA_CERT` for a private CA) whenever the + database is on another host. `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 +- A configured `ANTHROPIC_KEY` sends authenticated note text to Anthropic for + titles/summaries; set `AI_METADATA_ENABLED=false` to keep note text local, and + disclose the model use to users when it stays on. 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 diff --git a/__tests__/appUrl.test.ts b/__tests__/appUrl.test.ts index 93966c4..6392991 100644 --- a/__tests__/appUrl.test.ts +++ b/__tests__/appUrl.test.ts @@ -23,4 +23,18 @@ describe("canonical application origin", () => { }); expect(isSameOriginMutation(request)).toBe(false); }); + + it("trusts the browser's same-origin assertion behind a reverse proxy", () => { + // Public Origin header, internal listen URL — the shape every production + // request has behind Caddy. Sec-Fetch-Site must decide, not a comparison + // against the internal origin. + const request = new Request("http://localhost:3000/api/auth/revoke", { + method: "POST", + headers: { + origin: "https://keeptxt.com", + "sec-fetch-site": "same-origin", + }, + }); + expect(isSameOriginMutation(request)).toBe(true); + }); }); diff --git a/__tests__/dbCaCert.test.ts b/__tests__/dbCaCert.test.ts index 5c44f62..73df6f7 100644 --- a/__tests__/dbCaCert.test.ts +++ b/__tests__/dbCaCert.test.ts @@ -11,8 +11,10 @@ afterEach(() => { }); describe("databaseSslOptions", () => { - it("verifies certificates by default for TLS connections", () => { - expect(databaseSslOptions("require")).toEqual({ rejectUnauthorized: true }); + it("follows libpq verification semantics per sslmode", () => { + expect(databaseSslOptions("require")).toEqual({ rejectUnauthorized: false }); + expect(databaseSslOptions("prefer")).toEqual({ rejectUnauthorized: false }); + expect(databaseSslOptions("verify-ca")).toEqual({ rejectUnauthorized: true }); expect(databaseSslOptions("verify-full")).toEqual({ rejectUnauthorized: true }); }); diff --git a/__tests__/noteValidation.test.ts b/__tests__/noteValidation.test.ts index a893808..938fb32 100644 --- a/__tests__/noteValidation.test.ts +++ b/__tests__/noteValidation.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; import { isNoteColor } from "@/lib/noteColors"; -import { tagsInvalid, MAX_TAGS, MAX_TAG_LEN } from "@/lib/noteLimits"; +import { + tagsInvalid, + MAX_NOTE_BODY, + MAX_NOTE_REQUEST_BYTES, + MAX_TAGS, + MAX_TAG_LEN, +} from "@/lib/noteLimits"; describe("isNoteColor", () => { it("accepts palette keys and rejects anything else", () => { @@ -12,6 +18,17 @@ describe("isNoteColor", () => { }); }); +describe("MAX_NOTE_REQUEST_BYTES", () => { + it("admits a maximum-length note in its worst-case JSON encoding", () => { + // Control characters JSON-escape to \uXXXX — 6 bytes per UTF-16 char, the + // densest encoding a valid MAX_NOTE_BODY-char body can reach. + const body = String.fromCharCode(1).repeat(MAX_NOTE_BODY); + expect(Buffer.byteLength(JSON.stringify({ body }))).toBeLessThanOrEqual( + MAX_NOTE_REQUEST_BYTES, + ); + }); +}); + describe("tagsInvalid", () => { it("accepts a normal tag array", () => { expect(tagsInvalid(["work", "ideas"])).toBe(false); diff --git a/__tests__/offlineDb.test.ts b/__tests__/offlineDb.test.ts index 4a7b3f8..a78feec 100644 --- a/__tests__/offlineDb.test.ts +++ b/__tests__/offlineDb.test.ts @@ -31,23 +31,24 @@ 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 () => { + it("migrates un-replayed legacy pending ops into the first per-owner DB", async () => { const legacy = await openDB("keep-offline", 1, { upgrade(db) { db.createObjectStore("notes", { keyPath: "id" }); db.createObjectStore("pending", { keyPath: "id" }); }, }); - await legacy.put("pending", { + const op = { id: "legacy-create", type: "create", noteId: "legacy-note", - payload: note("legacy-note", "another account's private draft"), + payload: note("legacy-note", "offline draft made before the per-owner split"), createdAt: 1, - }); + }; + await legacy.put("pending", op); legacy.close(); - await expect(getPendingOps(`new-owner-${crypto.randomUUID()}`)).resolves.toEqual([]); + await expect(getPendingOps(`new-owner-${crypto.randomUUID()}`)).resolves.toEqual([op]); }); it("never returns one account's cached notes to another account", async () => { diff --git a/__tests__/titleModelSecurity.test.ts b/__tests__/titleModelSecurity.test.ts index a1d5b80..355ae42 100644 --- a/__tests__/titleModelSecurity.test.ts +++ b/__tests__/titleModelSecurity.test.ts @@ -8,7 +8,8 @@ afterEach(() => { }); describe("AI metadata privacy gate", () => { - it("does not send note text when explicit opt-in is absent", async () => { + it("keeps note text local when AI metadata is explicitly disabled", async () => { + process.env.AI_METADATA_ENABLED = "false"; process.env.ANTHROPIC_KEY = "test-key"; const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -19,6 +20,18 @@ describe("AI metadata privacy gate", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("enables AI metadata from a configured key without an extra flag", async () => { + 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 expect(generateNoteMeta("some note body")).resolves.toMatchObject({ + title: "Generated", + }); + }); + it("caps provider text at 8 KiB when enabled", async () => { process.env.AI_METADATA_ENABLED = "true"; process.env.ANTHROPIC_KEY = "test-key"; diff --git a/app/api/notes/[id]/route.ts b/app/api/notes/[id]/route.ts index 17424bb..bc2f2be 100644 --- a/app/api/notes/[id]/route.ts +++ b/app/api/notes/[id]/route.ts @@ -5,6 +5,7 @@ import { internalError } from "@/lib/apiError"; import { isNoteColor } from "@/lib/noteColors"; import { MAX_NOTE_BODY, + MAX_NOTE_REQUEST_BYTES, MAX_NOTE_SUMMARY, MAX_NOTE_TITLE, tagsInvalid, @@ -27,7 +28,6 @@ const ALLOWED = new Set([ "highlight", "tags", ]); -const MAX_NOTE_REQUEST_BYTES = MAX_NOTE_BODY + 16 * 1024; export async function PATCH( req: Request, diff --git a/app/api/notes/route.ts b/app/api/notes/route.ts index 55a0bd1..41554ee 100644 --- a/app/api/notes/route.ts +++ b/app/api/notes/route.ts @@ -6,6 +6,7 @@ import { heuristicNoteMeta } from "@/lib/titleModel"; import { internalError } from "@/lib/apiError"; import { MAX_NOTE_BODY, + MAX_NOTE_REQUEST_BYTES, MAX_NOTE_SUMMARY, MAX_NOTE_TITLE, MAX_NOTES_PER_USER, @@ -18,7 +19,6 @@ 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(); diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 3d9a988..2261b0b 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -45,22 +45,49 @@ export async function POST(req: Request) { const id = newId(); const path = `keep/${session.user.id}/${id}.${imageExtension(file.type)}`; const client = await pool().connect(); + // Storage keys freed by the orphan sweep; their objects are deleted only + // after the row deletions have committed. + let reclaimed: string[] = []; + let overQuota = false; try { await client.query("BEGIN"); await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [session.user.id]); - const usage = await client.query<{ bytes: string }>( - `SELECT coalesce(sum(size), 0)::text AS bytes FROM uploads WHERE user_id = $1`, - [session.user.id], - ); - if (Number(usage.rows[0]?.bytes ?? 0) + file.size > MAX_USER_UPLOAD_BYTES) { - await client.query("ROLLBACK"); - return NextResponse.json({ error: "Upload storage quota exceeded" }, { status: 413 }); + const usedBytes = async () => { + const usage = await client.query<{ bytes: string }>( + `SELECT coalesce(sum(size), 0)::text AS bytes FROM uploads WHERE user_id = $1`, + [session.user.id], + ); + return Number(usage.rows[0]?.bytes ?? 0); + }; + if ((await usedBytes()) + file.size > MAX_USER_UPLOAD_BYTES) { + // Images edited out of every note body would otherwise hold quota + // forever; reclaim them before failing the upload. Uploads from the + // last minute are spared — their reference may still be sitting in an + // editor waiting on autosave. + const orphaned = await client.query<{ storage_key: string }>( + `DELETE FROM uploads u + WHERE u.user_id = $1 + AND u.created_at < $2 + AND NOT EXISTS ( + SELECT 1 FROM notes n + WHERE n.user_id = $1 + AND position('/api/uploads/' || u.id in n.body) > 0 + ) + RETURNING storage_key`, + [session.user.id, Date.now() - 60_000], + ); + reclaimed = orphaned.rows.map((row) => row.storage_key); + overQuota = (await usedBytes()) + file.size > MAX_USER_UPLOAD_BYTES; } - await client.query( - `INSERT INTO uploads (id, user_id, storage_key, content_type, size, created_at) - VALUES ($1, $2, $3, $4, $5, $6)`, - [id, session.user.id, path, file.type, file.size, Date.now()], - ); + if (!overQuota) { + await client.query( + `INSERT INTO uploads (id, user_id, storage_key, content_type, size, created_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + [id, session.user.id, path, file.type, file.size, Date.now()], + ); + } + // Commit either way so a sweep that freed rows sticks even when the + // upload itself is still rejected. await client.query("COMMIT"); } catch (err) { await client.query("ROLLBACK").catch(() => {}); @@ -68,6 +95,12 @@ export async function POST(req: Request) { } finally { client.release(); } + for (const key of reclaimed) { + await deletePrivateFile(key).catch(() => {}); + } + if (overQuota) { + return NextResponse.json({ error: "Upload storage quota exceeded" }, { status: 413 }); + } try { await putPrivateFile(path, bytes, file.type); } catch (err) { diff --git a/auth.ts b/auth.ts index a37d41e..f8df597 100644 --- a/auth.ts +++ b/auth.ts @@ -105,9 +105,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ ); const currentVersion = rows[0]?.session_version; if (currentVersion === undefined) return null; - if (user || token.sessionVersion === undefined) { + if (user) { token.sessionVersion = currentVersion; } else if (token.sessionVersion !== currentVersion) { + // Tokens minted before session versioning carry no claim and fail this + // comparison too — grandfathering them would exempt every pre-deploy + // session from revocation forever (auth() in route handlers can't + // persist an adopted claim back into the cookie). Those users simply + // re-authenticate once. return null; } return token; diff --git a/components/SignOutButton.tsx b/components/SignOutButton.tsx index 77f18f8..dcdfa92 100644 --- a/components/SignOutButton.tsx +++ b/components/SignOutButton.tsx @@ -1,18 +1,31 @@ "use client"; import { signOut } from "next-auth/react"; -import { clearOwnerData } from "@/lib/offlineDb"; +import { clearOwnerData, getPendingOps } from "@/lib/offlineDb"; export function SignOutButton({ ownerId }: { ownerId: string }) { async function handleSignOut() { + // Queued offline edits die with clearOwnerData; make that an informed + // choice instead of silent loss. + const pending = await getPendingOps(ownerId).catch(() => []); + if ( + pending.length > 0 && + !window.confirm( + "Some note changes haven't synced yet and will be lost if you sign out now. Sign out anyway?", + ) + ) { + return; + } await clearOwnerData(ownerId).catch(() => {}); try { localStorage.setItem("keep.signout", JSON.stringify({ ownerId, at: Date.now() })); localStorage.removeItem("keep.signout"); } catch { - // Storage may be disabled; server-side session revocation still proceeds. + // Storage may be disabled; this browser's session still ends below. } - await fetch("/api/auth/revoke", { method: "POST" }).catch(() => null); + // Ends only this browser's session. Revoking every device (POST + // /api/auth/revoke) is a distinct, explicit action — a plain "Sign out" + // must not silently log out the user's phone and other browsers. await signOut({ callbackUrl: "/signin" }); } diff --git a/lib/appUrl.ts b/lib/appUrl.ts index f71c808..5cf9c80 100644 --- a/lib/appUrl.ts +++ b/lib/appUrl.ts @@ -9,8 +9,12 @@ export function appOrigin(req: Request): string { } export function isSameOriginMutation(req: Request): boolean { + // Sec-Fetch-Site is browser-asserted and authoritative when present. Behind + // the reverse proxy the request URL is the internal listen origin, so the + // Origin header is only compared (against the configured public origin) for + // older browsers that don't send Sec-Fetch-Site. const fetchSite = req.headers.get("sec-fetch-site"); - if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") return false; + if (fetchSite) return fetchSite === "same-origin" || fetchSite === "none"; const origin = req.headers.get("origin"); return !origin || origin === appOrigin(req); } diff --git a/lib/db.ts b/lib/db.ts index 9b0eb7b..5567a8d 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -5,7 +5,7 @@ import { logger } from "@/lib/logger"; // CA certificate for verifying the Postgres server's TLS cert. DATABASE_CA_CERT // may be the PEM contents inline or a path to a .pem/.crt file. When set, the // connection verifies the server identity (rejectUnauthorized: true); when -// unset we fall back to TLS without certificate verification for self-signed localhost. +// unset, verification follows the URL's sslmode (see databaseSslOptions). export function caCert(): string | undefined { const raw = process.env.DATABASE_CA_CERT; if (!raw || !raw.trim()) return undefined; @@ -71,9 +71,11 @@ export function databaseSslOptions( "sslmode=no-verify requires DATABASE_TLS_INSECURE=true; never use it across a network", ); } - return ca - ? { ca, rejectUnauthorized: true } - : { rejectUnauthorized: !allowInsecure }; + if (ca) return { ca, rejectUnauthorized: true }; + // libpq semantics: require/prefer encrypt without authenticating the server + // (a self-signed same-host Postgres keeps working); verify-ca/verify-full — + // or a configured DATABASE_CA_CERT above — turn certificate verification on. + return { rejectUnauthorized: sslmode === "verify-ca" || sslmode === "verify-full" }; } export function pool(): Pool { diff --git a/lib/noteLimits.ts b/lib/noteLimits.ts index befc7c1..cbd8ffd 100644 --- a/lib/noteLimits.ts +++ b/lib/noteLimits.ts @@ -1,6 +1,10 @@ // Abuse caps shared by the create and update note routes. Body and tags are // otherwise unbounded, which is a storage-exhaustion vector. -export const MAX_NOTE_BODY = 256 * 1024; // 256 KB — generous for a single note +export const MAX_NOTE_BODY = 256 * 1024; // 256K UTF-16 chars — generous for a single note +// Byte cap for the JSON request wrapping a note. MAX_NOTE_BODY counts UTF-16 +// characters, and one character can JSON-encode to up to 6 UTF-8 bytes +// (\uXXXX); sizing for the worst case keeps large multibyte notes saveable. +export const MAX_NOTE_REQUEST_BYTES = MAX_NOTE_BODY * 6 + 16 * 1024; export const MAX_NOTE_TITLE = 120; export const MAX_NOTE_SUMMARY = 500; export const MAX_TAGS = 50; diff --git a/lib/offlineDb.ts b/lib/offlineDb.ts index 114f249..e305ef9 100644 --- a/lib/offlineDb.ts +++ b/lib/offlineDb.ts @@ -22,18 +22,29 @@ function databaseName(ownerId: string) { return `${DB_PREFIX}:${encodeURIComponent(ownerId)}`; } -// The legacy database wasn't owner-scoped. Pending creates contain full note -// bodies, so assigning them to whichever account opens first can disclose one -// person's offline note to another person on a shared browser profile. There is -// no reliable owner identity to recover; discard the database in full. -async function discardLegacyDatabase() { +// The pre-per-owner "keep-offline" DB may still hold un-replayed pending ops +// (edits/deletes made offline that never reached the server). Move them into +// the first per-owner DB that opens so they replay — matching where they would +// have replayed before the per-owner split — then drop the legacy DB. Cached +// notes are intentionally not migrated: they are just copies of server rows a +// refresh() will repopulate, and the legacy DB wasn't owner-scoped, so seeding +// them could surface a previous account's notes on a shared device. +async function migrateLegacyDatabase(target: IDBPDatabase) { if (legacyMigrationStarted) return; legacyMigrationStarted = true; let legacy: IDBPDatabase | null = null; try { legacy = await openDB(LEGACY_DB_NAME, DB_VERSION); + if (legacy.objectStoreNames.contains("pending")) { + const ops = await legacy.getAll("pending"); + if (ops.length > 0) { + const tx = target.transaction("pending", "readwrite"); + for (const op of ops) await tx.store.put(op); + await tx.done; + } + } } catch { - /* no legacy DB, or it was unreadable */ + /* no legacy DB, or it was unreadable — nothing to migrate */ } finally { legacy?.close(); await deleteDB(LEGACY_DB_NAME).catch(() => {}); @@ -51,7 +62,7 @@ function getDb(ownerId: string) { pending.createIndex("createdAt", "createdAt"); }, }).then(async (db) => { - await discardLegacyDatabase(); + await migrateLegacyDatabase(db); return db; }); dbPromises.set(name, promise); diff --git a/lib/titleModel.ts b/lib/titleModel.ts index 717bec7..f83fea5 100644 --- a/lib/titleModel.ts +++ b/lib/titleModel.ts @@ -58,7 +58,9 @@ export async function generateNoteMeta(body: string): Promise { title: inferNoteTitle(body), summary: heuristicSummary(body), }; - if (process.env.AI_METADATA_ENABLED !== "true") return fallback; + // A configured key enables AI metadata; AI_METADATA_ENABLED=false is the + // explicit off-switch for operators who keep a key but want note text local. + if (process.env.AI_METADATA_ENABLED === "false") return fallback; const apiKey = process.env.ANTHROPIC_KEY || process.env.ANTHROPIC_API_KEY; if (!apiKey || !body.trim()) return fallback; diff --git a/proxy.ts b/proxy.ts index 8a330f8..4cefe3e 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,5 @@ import NextAuth from "next-auth"; -import { NextResponse } from "next/server"; +import { NextResponse, type NextRequest } from "next/server"; import { authConfig } from "@/auth.config"; import { createTokenBucketRateLimiter } from "@/lib/rateLimit"; import { enforceIpRateLimit } from "@/lib/rateLimitGuard"; @@ -25,6 +25,55 @@ const publicShareRateLimit = createTokenBucketRateLimiter({ windowMs: 60_000, }); +// Cross-origin mutation gate. Sec-Fetch-Site is browser-asserted and +// authoritative when present; the Origin comparison is only a fallback for +// older browsers. req.nextUrl.origin alone is the wrong reference — under +// self-hosted `next start` behind Caddy it is the *listen* origin +// (https://localhost:3000), so comparing the public Origin header against it +// would 403 every same-origin mutation in production. +function allowedOrigins(req: NextRequest): Set { + const origins = new Set([req.nextUrl.origin]); + if (process.env.AUTH_URL) { + try { + origins.add(new URL(process.env.AUTH_URL).origin); + } catch { + // Malformed AUTH_URL — the forwarded-host fallback below still applies. + } + } + const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host"); + if (host) { + const proto = + req.headers.get("x-forwarded-proto")?.split(",")[0].trim() || + req.nextUrl.protocol.replace(":", ""); + origins.add(`${proto}://${host}`); + } + return origins; +} + +function isCrossOriginMutation(req: NextRequest): boolean { + const fetchSite = req.headers.get("sec-fetch-site"); + if (fetchSite) return fetchSite !== "same-origin" && fetchSite !== "none"; + const origin = req.headers.get("origin"); + return origin !== null && !allowedOrigins(req).has(origin); +} + +// Note bodies saved before uploads went private embed absolute URLs on the old +// public bucket/CDN origin; keep exactly that origin renderable so those notes +// don't break. New uploads stream same-origin via /api/uploads/. +const legacyImageOrigins = [ + ...new Set( + [process.env.S3_PUBLIC_BASE_URL, process.env.S3_ENDPOINT] + .filter((value): value is string => Boolean(value)) + .flatMap((value) => { + try { + return [new URL(value).origin]; + } catch { + return []; + } + }), + ), +].join(" "); + function buildCsp(nonce: string): string { return [ `default-src 'self'`, @@ -36,7 +85,7 @@ function buildCsp(nonce: string): string { `worker-src 'self' blob:`, // Uploaded images now stream through the authenticated same-origin route. // Blocking arbitrary remote images also blocks markdown tracking pixels. - `img-src 'self' data: blob:`, + `img-src 'self' data: blob:${legacyImageOrigins ? ` ${legacyImageOrigins}` : ""}`, `font-src 'self'`, `connect-src 'self'${isDev ? " ws:" : ""}`, `base-uri 'self'`, @@ -65,15 +114,8 @@ function withCsp(requestHeaders: Headers): NextResponse { export default auth((req) => { const { pathname } = req.nextUrl; - if (!["GET", "HEAD", "OPTIONS"].includes(req.method)) { - const fetchSite = req.headers.get("sec-fetch-site"); - const origin = req.headers.get("origin"); - if ( - (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") || - (origin && origin !== req.nextUrl.origin) - ) { - return NextResponse.json({ error: "Cross-origin request blocked" }, { status: 403 }); - } + if (!["GET", "HEAD", "OPTIONS"].includes(req.method) && isCrossOriginMutation(req)) { + return NextResponse.json({ error: "Cross-origin request blocked" }, { status: 403 }); } if (pathname.startsWith("/p/")) {