← 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).*)',
  ],
};