← back to Trademarks Copyright

src/middleware.ts

122 lines

import { NextResponse, type NextRequest } from "next/server";

/**
 * Two-layer gate:
 *  1) Site-wide HTTP Basic Auth (BASIC_AUTH = "user:password", default
 *     "admin:DWSecure2024!"). Skipped for /health, /healthz, /_next/*,
 *     and /favicon.ico.
 *  2) Admin-token gate on /admin/*, /api/drops/admin/*, and the previously-
 *     unauth mutating routes (/api/drops/send, /api/drops/compose, /api/score,
 *     /api/swot, /api/brands/hunt). Requires ADMIN_TOKEN via ?token=… or the
 *     drops_admin cookie. Fail-closed in production.
 */
const BASIC_AUTH = process.env.BASIC_AUTH || "admin:DWSecure2024!";
const EXPECTED_BASIC = "Basic " + Buffer.from(BASIC_AUTH).toString("base64");

export async function middleware(req: NextRequest) {
  const { pathname, searchParams } = req.nextUrl;

  // ---- Layer 1: site-wide Basic Auth ----
  const skipBasic =
    pathname === "/health" ||
    pathname === "/healthz" ||
    pathname.startsWith("/_next/") ||
    pathname === "/favicon.ico";

  if (!skipBasic) {
    const authHeader = req.headers.get("authorization");
    if (authHeader !== EXPECTED_BASIC) {
      return new NextResponse("auth required", {
        status: 401,
        headers: { "WWW-Authenticate": 'Basic realm="mac2-pm2"' },
      });
    }
  }

  // ---- Layer 2: admin-token gate on sensitive routes ----
  // SECURITY (P0 fix 2026-05-04): expanded gate to cover the unauthenticated
  // mutating routes a code-review surfaced. Before this, /api/drops/send,
  // /api/drops/compose, /api/score, /api/swot, /api/brands/hunt were ALL
  // public POSTs that could blast email to all subscribers, burn unbounded
  // qwen3:14b cycles, or mass-rewrite composite_score on every item.
  const needsAuth = pathname.startsWith("/admin")
    || pathname.startsWith("/api/drops/admin")
    || pathname === "/api/drops/send"
    || pathname === "/api/drops/compose"
    || pathname === "/api/score"
    || pathname === "/api/swot"
    || pathname === "/api/brands/hunt";
  if (!needsAuth) return NextResponse.next();

  const required = process.env.ADMIN_TOKEN;

  if (!required) {
    if (process.env.NODE_ENV === "production") {
      // Fail-closed — do not accept any request until ADMIN_TOKEN is set.
      if (pathname.startsWith("/api/")) {
        return NextResponse.json({ error: "admin disabled — ADMIN_TOKEN not configured" }, { status: 503 });
      }
      return new NextResponse(
        `<!doctype html><html><body style="font-family:Georgia,serif;max-width:420px;margin:80px auto;padding:20px;">
          <h1>Admin disabled</h1>
          <p>Set <code>ADMIN_TOKEN</code> in environment and restart.</p>
        </body></html>`,
        { status: 503, headers: { "content-type": "text/html" } }
      );
    }
    return NextResponse.next(); // dev mode — no gate
  }

  const paramToken = searchParams.get("token");
  const cookie = req.cookies.get("drops_admin")?.value;
  const provided = paramToken ?? cookie ?? "";

  if (provided && (await constantTimeEqual(provided, required))) {
    const res = NextResponse.next();
    if (paramToken) res.cookies.set("drops_admin", paramToken, {
      httpOnly: true, sameSite: "lax", secure: true, maxAge: 60 * 60 * 24 * 30, path: "/",
    });
    return res;
  }

  if (pathname.startsWith("/api/")) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }
  return new NextResponse(
    `<!doctype html><html><body style="font-family:Georgia,serif;max-width:400px;margin:80px auto;padding:20px;">
       <h1>Admin access</h1>
       <p>Append <code>?token=YOUR_ADMIN_TOKEN</code> to the URL to enter.</p>
     </body></html>`,
    { status: 401, headers: { "content-type": "text/html" } }
  );
}

export const config = {
  // Run on every request so site-wide Basic Auth (Layer 1) covers all pages
  // and routes; per-path admin-token logic (Layer 2) lives inside middleware().
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

/**
 * HMAC-hash-compare: length-oblivious by design.
 * Both inputs are hashed with the same key first, so the comparison happens
 * on fixed-length SHA-256 digests. Runs on the Edge runtime (no node:crypto).
 */
async function constantTimeEqual(a: string, b: string): Promise<boolean> {
  const enc = new TextEncoder();
  // Fixed key — rotating it would invalidate cookies, which is a feature if
  // ADMIN_TOKEN changes. We just need ≠-length inputs to hash to same-length.
  const key = await crypto.subtle.importKey(
    "raw", enc.encode("drops-admin-v1"),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const [ha, hb] = await Promise.all([
    crypto.subtle.sign("HMAC", key, enc.encode(a)),
    crypto.subtle.sign("HMAC", key, enc.encode(b)),
  ]);
  const da = new Uint8Array(ha), db = new Uint8Array(hb);
  let diff = da.length ^ db.length;
  for (let i = 0; i < da.length; i++) diff |= da[i] ^ db[i];
  return diff === 0;
}