From d485695357f14613594c889e6078557d3cc14731 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 3 Jun 2026 03:38:12 +0200 Subject: [PATCH] fix: CSRF host-compare behind proxy + default port = creation order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two prod-only breakages found after go-live: 1. CSRF guard rejected EVERY /api/v1 mutation ("Cross-origin state-changing request rejected", 403) — making the CRM read-only. It compared the browser Origin (https://crm.portnimara.com) against request.nextUrl.origin, but TLS terminates at nginx so the app sees http://127.0.0.1 → protocol mismatch. Compare hosts instead (Host header survives the proxy; a cross-site attacker can't forge the browser-set Origin host). 2. Post-login landed on port-amador (empty tenant), not port-nimara. Three queries ordered ports by name (alphabetical → Amador first): the bare /dashboard redirect (app/dashboard/page.tsx), the dashboard layout's defaultPortId, and /api/v1/me/ports. Order by createdAt so the primary (first-seeded) port — Port Nimara — leads, matching listPorts(). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(dashboard)/layout.tsx | 2 +- src/app/api/v1/me/ports/route.ts | 2 +- src/app/dashboard/page.tsx | 2 +- src/proxy.ts | 23 +++++++++++++++-------- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 2ede42c9..c9776f0e 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -38,7 +38,7 @@ export default async function DashboardLayout({ children }: { children: React.Re }); const ports = profile?.isSuperAdmin - ? await db.query.ports.findMany({ orderBy: portsTable.name }) + ? await db.query.ports.findMany({ orderBy: portsTable.createdAt }) : portRoles.map((pr) => pr.port); // Prefer a previously-resolved tier from the client's cookie so the diff --git a/src/app/api/v1/me/ports/route.ts b/src/app/api/v1/me/ports/route.ts index cb7755d4..a79f60bd 100644 --- a/src/app/api/v1/me/ports/route.ts +++ b/src/app/api/v1/me/ports/route.ts @@ -39,7 +39,7 @@ export async function GET() { if (profile.isSuperAdmin) { const all = await db.query.ports.findMany({ where: eq(portsTable.isActive, true), - orderBy: portsTable.name, + orderBy: portsTable.createdAt, columns: { id: true, slug: true, name: true }, }); return NextResponse.json({ data: all }); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 2260a352..36bb0e2d 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -47,7 +47,7 @@ export default async function DashboardRedirectPage() { if (!slug) { if (profile?.isSuperAdmin) { - const first = await db.query.ports.findFirst({ orderBy: portsTable.name }); + const first = await db.query.ports.findFirst({ orderBy: portsTable.createdAt }); slug = first?.slug; } else { const role = await db.query.userPortRoles.findFirst({ diff --git a/src/proxy.ts b/src/proxy.ts index b5dfd580..d22496ca 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -125,17 +125,24 @@ function isOriginCheckedPath(pathname: string): boolean { function originAllowed(request: NextRequest): boolean { const origin = request.headers.get('origin'); const referer = request.headers.get('referer'); - // Same-origin fetch from the app sends both Origin AND a matching host. - // Use request.nextUrl.origin (the deployed origin) as the source of truth. - const expectedOrigin = request.nextUrl.origin; - if (origin) return origin === expectedOrigin; - if (referer) { + // Compare HOSTS, not full origins. TLS terminates at the reverse proxy, + // so the upstream request the app sees is http://127.0.0.1 — its + // protocol is unreliable, and request.nextUrl.origin reads `http` while + // the browser's Origin is `https`, which would reject every same-site + // mutation in production. The Host header is preserved across the proxy, + // and a matching host is what same-origin CSRF defense actually needs + // (a cross-site attacker can't forge the browser-set Origin host). + const expectedHost = request.nextUrl.host; + const hostOf = (value: string | null): string | null => { + if (!value) return null; try { - return new URL(referer).origin === expectedOrigin; + return new URL(value).host; } catch { - return false; + return null; } - } + }; + if (origin) return hostOf(origin) === expectedHost; + if (referer) return hostOf(referer) === expectedHost; // Neither header present: most browser fetches always send Origin on // POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side // call (e.g. Next.js internal fetch). Allow.