← 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;
}