← back to Stayclaim
src/middleware.ts
125 lines
/**
* Host-based routing for the three-domain pastdoor ecosystem.
*
* - wholivedthere.com → flagship, no rewrite (root archive)
* - claimmyaddress.com → onboarding, redirect / → /submit
* - bubbesblock.com → community, redirect / → /neighborhoods
*
* All three domains hit the same Next.js process via nginx host routing.
* This file plus the per-host metadata helper in lib/site.ts are the only
* changes needed in stayclaim to support the three-domain ecosystem.
*
* Header `x-pastdoor-host` is set so server components downstream can read
* the originating host even after rewrites/redirects.
*/
import { NextRequest, NextResponse } from 'next/server';
const HOST_ROUTES: Record<string, string | null> = {
'wholivedthere.com': null, // root index
'www.wholivedthere.com': null,
'claimmyaddress.com': '/submit',
'www.claimmyaddress.com': '/submit',
'bubbesblock.com': '/neighborhoods',
'www.bubbesblock.com': '/neighborhoods',
// dev / staging
'localhost:9821': null,
'pastdoor.com': null,
'stayclaim.com': null,
};
// SECURITY (P0 fix 2026-05-04): /admin/* was completely unauthenticated —
// admin/layout.tsx literally said "auth: dev-mode (no auth)". Anyone hitting
// {wholivedthere,claimmyaddress,bubbesblock}.com/admin saw the full PG
// dashboard, claim-request emails, and sponsored-placement contact_email.
// Gate via HTTP Basic against ADMIN_SECRET in env.
function adminGate(req: NextRequest): NextResponse | null {
const adminSecret = process.env.ADMIN_SECRET || '';
if (!adminSecret) {
// Fail-closed when secret unset in production; allow in dev.
if (process.env.NODE_ENV === 'production') {
return new NextResponse('Admin not configured', { status: 503 });
}
return null;
}
const auth = req.headers.get('authorization') || '';
const [scheme, creds] = auth.split(' ');
if (scheme !== 'Basic' || !creds) {
return new NextResponse('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="stayclaim-admin"' },
});
}
const decoded = Buffer.from(creds, 'base64').toString();
const expected = `admin:${adminSecret}`;
// constant-time compare to avoid trivial timing oracle
if (decoded.length !== expected.length) {
return new NextResponse('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="stayclaim-admin"' },
});
}
let mismatch = 0;
for (let i = 0; i < decoded.length; i++) mismatch |= decoded.charCodeAt(i) ^ expected.charCodeAt(i);
if (mismatch !== 0) {
return new NextResponse('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="stayclaim-admin"' },
});
}
return null;
}
export function middleware(req: NextRequest) {
const rawHost = (req.headers.get('host') || '').toLowerCase();
const url = req.nextUrl;
// P0 fix: gate /admin/* in middleware so EVERY admin route inherits auth,
// including admin/data/page.tsx (397-line arbitrary-table reader).
if (url.pathname.startsWith('/admin')) {
const blocked = adminGate(req);
if (blocked) return blocked;
}
// P0 fix: /dashboard?email=victim was a trivial IDOR — block in production
// until real auth ships. Dev still has it for local testing.
if (url.pathname === '/dashboard' && process.env.NODE_ENV === 'production') {
return new NextResponse('Not Found', { status: 404 });
}
// Normalize: strip port for production hosts (proxies sometimes forward `host:443`).
// Preserve `localhost:9821` verbatim since dev key includes the port.
const hostNoPort = rawHost === 'localhost:9821' ? rawHost : rawHost.replace(/:\d+$/, '');
// Strip www. for canonical comparison; preserve in headers
const canonicalHost = hostNoPort.replace(/^www\./, '');
// Look up against the normalized host to honor port-suffixed Host headers.
const routeKey = Object.prototype.hasOwnProperty.call(HOST_ROUTES, hostNoPort) ? hostNoPort : null;
const routeTarget = routeKey ? HOST_ROUTES[routeKey] : undefined;
// Surface header for downstream components (per-host SEO, CTAs, theme)
const res = url.pathname === '/' && routeTarget !== undefined && routeTarget !== null
? NextResponse.rewrite(new URL(routeTarget, req.url))
: NextResponse.next();
// Only forward x-pastdoor-host for hosts in our allowlist; otherwise downstream
// server components would receive attacker-controlled Host values.
if (routeKey !== null) {
res.headers.set('x-pastdoor-host', canonicalHost);
}
return res;
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next (internal)
* - api routes (host routing not relevant; /admin auth is page-route only)
* - static files (have a file extension)
* - favicons + robots + sitemap
* If any /api/admin/* routes are added later, gate them inline (not here).
*/
'/((?!_next|api|.*\\.[a-z]+$|favicon.ico|robots.txt|sitemap.xml).*)',
],
};