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
|
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);
|
: portRoles.map((pr) => pr.port);
|
||||||
|
|
||||||
// Prefer a previously-resolved tier from the client's cookie so the
|
// Prefer a previously-resolved tier from the client's cookie so the
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function GET() {
|
|||||||
if (profile.isSuperAdmin) {
|
if (profile.isSuperAdmin) {
|
||||||
const all = await db.query.ports.findMany({
|
const all = await db.query.ports.findMany({
|
||||||
where: eq(portsTable.isActive, true),
|
where: eq(portsTable.isActive, true),
|
||||||
orderBy: portsTable.name,
|
orderBy: portsTable.createdAt,
|
||||||
columns: { id: true, slug: true, name: true },
|
columns: { id: true, slug: true, name: true },
|
||||||
});
|
});
|
||||||
return NextResponse.json({ data: all });
|
return NextResponse.json({ data: all });
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default async function DashboardRedirectPage() {
|
|||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
if (profile?.isSuperAdmin) {
|
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;
|
slug = first?.slug;
|
||||||
} else {
|
} else {
|
||||||
const role = await db.query.userPortRoles.findFirst({
|
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 {
|
function originAllowed(request: NextRequest): boolean {
|
||||||
const origin = request.headers.get('origin');
|
const origin = request.headers.get('origin');
|
||||||
const referer = request.headers.get('referer');
|
const referer = request.headers.get('referer');
|
||||||
// Same-origin fetch from the app sends both Origin AND a matching host.
|
// Compare HOSTS, not full origins. TLS terminates at the reverse proxy,
|
||||||
// Use request.nextUrl.origin (the deployed origin) as the source of truth.
|
// so the upstream request the app sees is http://127.0.0.1 — its
|
||||||
const expectedOrigin = request.nextUrl.origin;
|
// protocol is unreliable, and request.nextUrl.origin reads `http` while
|
||||||
if (origin) return origin === expectedOrigin;
|
// the browser's Origin is `https`, which would reject every same-site
|
||||||
if (referer) {
|
// 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 {
|
try {
|
||||||
return new URL(referer).origin === expectedOrigin;
|
return new URL(value).host;
|
||||||
} catch {
|
} 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
|
// Neither header present: most browser fetches always send Origin on
|
||||||
// POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side
|
// POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side
|
||||||
// call (e.g. Next.js internal fetch). Allow.
|
// call (e.g. Next.js internal fetch). Allow.
|
||||||
|
|||||||
Reference in New Issue
Block a user