fix: CSRF host-compare behind proxy + default port = creation order
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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
23
src/proxy.ts
23
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.
|
||||
|
||||
Reference in New Issue
Block a user