fix: CSRF host-compare behind proxy + default port = creation order
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m1s
Build & Push Docker Images / build-and-push (push) Successful in 7m30s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 03:38:12 +02:00
parent 23a5811342
commit d485695357
4 changed files with 18 additions and 11 deletions

View File

@@ -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

View File

@@ -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 });

View File

@@ -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({

View File

@@ -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.