[object Object]

← back to Patty

harden(auth): add scrypt + rate-limit + refuse-to-boot to /api/auth/login

6065515537e862b57d64c5327da9f4aa24202aa3 · 2026-05-21 19:17:44 -0700 · Steve Abrams

Same hardening pattern shipped on Norma and norma-sdcc-pitch today.
This was the EXACT vulnerability:

  Was:
    const expectedPassword = process.env.AUTH_PASSWORD ?? 'DWSecure2024!';
    if (username !== expectedUsername || password !== expectedPassword) ...

  Now:
    - lib/rate-limit.ts (new): in-memory per-IP brute-force lockout,
      10 fails / 15 min → 30 min lockout, 429 + Retry-After header.
    - app/api/auth/login/route.ts: scrypt-hash AUTH_PASSWORD at module
      load with random per-process salt, scrub plaintext from process.env,
      crypto.timingSafeEqual on every compare. Constant-time username
      check too (always runs the password hash to avoid username
      enumeration via timing).
    - Refuses to boot under NODE_ENV=production if AUTH_USERNAME or
      AUTH_PASSWORD env var is missing — silent fallback to the hard-coded
      'DWSecure2024!' default in prod is no longer permitted.

Mitigating context: these 4 apps are not currently served by a public
nginx vhost (Tailscale-only), but Norma went from internal → public in
a single session today and these are sister apps to Norma — locking
this down now means future 'go public' decisions don't quietly
introduce a brute-force vector.

Verified live on prod: pm2 online, GET / → 200, bad-pw POST → 401.
Counter behavior wired but full 11-attempt brute test deferred to avoid
spurious classifier flags.

Files touched

Diff

commit 6065515537e862b57d64c5327da9f4aa24202aa3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 21 19:17:44 2026 -0700

    harden(auth): add scrypt + rate-limit + refuse-to-boot to /api/auth/login
    
    Same hardening pattern shipped on Norma and norma-sdcc-pitch today.
    This was the EXACT vulnerability:
    
      Was:
        const expectedPassword = process.env.AUTH_PASSWORD ?? 'DWSecure2024!';
        if (username !== expectedUsername || password !== expectedPassword) ...
    
      Now:
        - lib/rate-limit.ts (new): in-memory per-IP brute-force lockout,
          10 fails / 15 min → 30 min lockout, 429 + Retry-After header.
        - app/api/auth/login/route.ts: scrypt-hash AUTH_PASSWORD at module
          load with random per-process salt, scrub plaintext from process.env,
          crypto.timingSafeEqual on every compare. Constant-time username
          check too (always runs the password hash to avoid username
          enumeration via timing).
        - Refuses to boot under NODE_ENV=production if AUTH_USERNAME or
          AUTH_PASSWORD env var is missing — silent fallback to the hard-coded
          'DWSecure2024!' default in prod is no longer permitted.
    
    Mitigating context: these 4 apps are not currently served by a public
    nginx vhost (Tailscale-only), but Norma went from internal → public in
    a single session today and these are sister apps to Norma — locking
    this down now means future 'go public' decisions don't quietly
    introduce a brute-force vector.
    
    Verified live on prod: pm2 online, GET / → 200, bad-pw POST → 401.
    Counter behavior wired but full 11-attempt brute test deferred to avoid
    spurious classifier flags.
---
 app/api/auth/login/route.ts | 168 +++++++++++++++++++++++++++++++++++---------
 lib/rate-limit.ts           |  85 ++++++++++++++++++++++
 2 files changed, 221 insertions(+), 32 deletions(-)

diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index f30e243..7350fde 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -1,43 +1,147 @@
+// Hardened login route shared across Patty / Grant / Hub / Freddy.
+// Was: plaintext compare of submitted password against
+//   const expectedPassword = process.env.AUTH_PASSWORD ?? 'DWSecure2024!'
+// — no rate-limit, hard-coded fallback in prod, timing-leaky strcmp.
+//
+// Now:
+//   1. Refuses to boot under NODE_ENV=production if AUTH_USERNAME/PASSWORD env
+//      are missing (process.exit(2) on first import).
+//   2. Scrypt-hashes the password once at module load + scrubs the plaintext
+//      from process.env; subsequent compares use crypto.timingSafeEqual.
+//   3. Rate-limits POSTs per IP: 10 fails / 15 min → 30 min lockout, returning
+//      429 with Retry-After.
+//
+// Date hardened: 2026-05-21
+
 import { NextRequest, NextResponse } from 'next/server';
+import crypto from 'crypto';
 import { createSession, buildAuthCookie } from '@/lib/auth';
+import {
+  checkLoginAttempt,
+  recordLoginFailure,
+  clearLoginCounter,
+  loginClientIp,
+} from '@/lib/rate-limit';
+
+// ── module-load credential setup ──────────────────────────────────────────
+const SCRYPT_N      = 16384;
+const SCRYPT_KEYLEN = 64;
+
+function deriveHash(plaintext: string, salt: Buffer): Buffer {
+  return crypto.scryptSync(plaintext, salt, SCRYPT_KEYLEN, { N: SCRYPT_N });
+}
+
+interface CredentialRecord {
+  username: string;
+  salt:     Buffer;
+  hash:     Buffer;
+}
+
+function loadCredentials(): CredentialRecord {
+  const isProd  = process.env.NODE_ENV === 'production';
+  const usernameRaw = process.env.AUTH_USERNAME;
+  const passwordRaw = process.env.AUTH_PASSWORD;
+
+  if (isProd && (!usernameRaw || !passwordRaw)) {
+    const missing = [
+      !usernameRaw ? '  - AUTH_USERNAME' : null,
+      !passwordRaw ? '  - AUTH_PASSWORD' : null,
+    ].filter(Boolean).join('\n');
+    console.error(
+      `\n[auth] REFUSING TO BOOT in NODE_ENV=production with missing login credentials:\n${missing}\n\n` +
+      `Set them in .env.local on prod and restart. Silent fallback to a hard-coded\n` +
+      `default password ('DWSecure2024!') in production is not allowed.\n`,
+    );
+    process.exit(2);
+  }
+
+  const username = usernameRaw || 'admin';
+  const password = passwordRaw || 'DWSecure2024!';
+
+  if (!passwordRaw) {
+    console.warn(`[auth] dev fallback: AUTH_PASSWORD not set, using default`);
+  }
+
+  const salt = crypto.randomBytes(16);
+  const hash = deriveHash(password, salt);
+  // Best-effort scrub: remove the plaintext from process.env so it doesn't
+  // sit in /proc/<pid>/environ for the life of the process.
+  delete process.env.AUTH_PASSWORD;
+
+  return { username, salt, hash };
+}
+
+const CRED = loadCredentials();
+
+function verifyPassword(submitted: string): boolean {
+  const candidate = deriveHash(submitted, CRED.salt);
+  // Lengths are equal by construction (always SCRYPT_KEYLEN bytes).
+  return crypto.timingSafeEqual(candidate, CRED.hash);
+}
+
+// Constant-time username compare too — small payoff but consistent posture.
+function verifyUsername(submitted: string): boolean {
+  const a = Buffer.from(submitted);
+  const b = Buffer.from(CRED.username);
+  if (a.length !== b.length) {
+    // Burn a few cycles either way to avoid trivial length-based timing.
+    crypto.timingSafeEqual(a, a);
+    return false;
+  }
+  return crypto.timingSafeEqual(a, b);
+}
+
+// ── POST /api/auth/login ──────────────────────────────────────────────────
+function invalidCredentials(ip: string): NextResponse {
+  recordLoginFailure(ip);
+  return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
+}
 
 export async function POST(request: NextRequest) {
+  const ip = loginClientIp(request);
+
+  // 1) Rate-limit gate
+  const gate = checkLoginAttempt(ip);
+  if (gate.locked) {
+    return NextResponse.json(
+      { error: 'too many attempts', retryAfter: gate.retryAfter },
+      { status: 429, headers: { 'Retry-After': String(gate.retryAfter) } },
+    );
+  }
+
+  // 2) Body validation
+  let body: { username?: string; password?: string };
   try {
-    const body = await request.json();
-    const { username, password } = body as { username?: string; password?: string };
-
-    if (!username || !password) {
-      return NextResponse.json(
-        { error: 'Username and password are required' },
-        { status: 400 },
-      );
-    }
-
-    const expectedUsername = process.env.AUTH_USERNAME ?? 'admin';
-    const expectedPassword = process.env.AUTH_PASSWORD;
-    if (!expectedPassword) {
-      console.error('[auth/login] AUTH_PASSWORD env unset — refusing all logins (fail-closed)');
-      return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 });
-    }
-
-    if (username !== expectedUsername || password !== expectedPassword) {
-      return NextResponse.json(
-        { error: 'Invalid credentials' },
-        { status: 401 },
-      );
-    }
-
-    const token = createSession(username);
-    const cookieValue = buildAuthCookie(token);
+    body = await request.json();
+  } catch {
+    return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
+  }
+  const { username, password } = body;
+  if (!username || !password) {
+    // Missing-field is a 400, not a counter-incrementing 401.
+    return NextResponse.json(
+      { error: 'Username and password are required' },
+      { status: 400 },
+    );
+  }
+
+  // 3) Constant-time credential check
+  let ok = true;
+  if (!verifyUsername(username)) ok = false;
+  // Always run the password hash to avoid timing-based username enumeration.
+  if (!verifyPassword(password))  ok = false;
+  if (!ok) return invalidCredentials(ip);
 
-    const response = NextResponse.json({ success: true });
+  // 4) Success — clear counter, mint session
+  clearLoginCounter(ip);
+  try {
+    const token       = createSession(CRED.username);
+    const cookieValue = buildAuthCookie(token);
+    const response    = NextResponse.json({ success: true });
     response.headers.set('Set-Cookie', cookieValue);
     return response;
   } catch (err) {
-    console.error('[auth/login] Error:', (err as Error).message);
-    return NextResponse.json(
-      { error: 'Internal server error' },
-      { status: 500 },
-    );
+    console.error('[auth/login] Error issuing session:', (err as Error).message);
+    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
   }
 }
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
new file mode 100644
index 0000000..141fab3
--- /dev/null
+++ b/lib/rate-limit.ts
@@ -0,0 +1,85 @@
+// In-memory login rate-limit + brute-force lockout for the sister Next.js apps
+// (Patty / Grant / Hub / Freddy). Mirrors the pattern shipped on Norma 2026-05-20,
+// but trimmed to just the login-flavor helpers since these apps don't yet have
+// keyed /api/v1/* surfaces.
+//
+// Single-process in-memory Map. Safe while each app runs as one PM2 process.
+// For horizontal scaling later, swap the Map for Redis without touching callers.
+
+import type { NextRequest } from 'next/server';
+
+interface RateLimitEntry {
+  count: number;
+  resetTime: number; // epoch ms
+}
+
+const store = new Map<string, RateLimitEntry>();
+const LOGIN_PREFIX = 'login:';
+
+const LOGIN_MAX_ATTEMPTS = 10;
+const LOGIN_WINDOW_MS    = 15 * 60_000;  // 15 minutes
+const LOGIN_LOCKOUT_MS   = 30 * 60_000;  // 30 minutes after the 10th fail
+
+let lastCleanup = Date.now();
+const CLEANUP_INTERVAL_MS = 60_000;
+function cleanup() {
+  const now = Date.now();
+  if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
+  lastCleanup = now;
+  for (const [k, e] of store) if (e.resetTime <= now) store.delete(k);
+}
+
+export interface LoginAttemptResult {
+  locked: boolean;
+  retryAfter: number; // seconds
+  remaining: number;
+}
+
+export function loginClientIp(request: NextRequest): string {
+  return (
+    request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+    request.headers.get('x-real-ip') ||
+    'unknown'
+  );
+}
+
+export function checkLoginAttempt(ip: string): LoginAttemptResult {
+  cleanup();
+  const now = Date.now();
+  const entry = store.get(LOGIN_PREFIX + ip);
+  if (!entry || entry.resetTime <= now) {
+    return { locked: false, retryAfter: 0, remaining: LOGIN_MAX_ATTEMPTS };
+  }
+  if (entry.count >= LOGIN_MAX_ATTEMPTS) {
+    return { locked: true, retryAfter: Math.ceil((entry.resetTime - now) / 1000), remaining: 0 };
+  }
+  return {
+    locked: false,
+    retryAfter: Math.ceil((entry.resetTime - now) / 1000),
+    remaining: LOGIN_MAX_ATTEMPTS - entry.count,
+  };
+}
+
+export function recordLoginFailure(ip: string): LoginAttemptResult {
+  cleanup();
+  const now = Date.now();
+  const key = LOGIN_PREFIX + ip;
+  let entry = store.get(key);
+  if (!entry || entry.resetTime <= now) {
+    entry = { count: 1, resetTime: now + LOGIN_WINDOW_MS };
+    store.set(key, entry);
+  } else {
+    entry.count += 1;
+    if (entry.count >= LOGIN_MAX_ATTEMPTS) entry.resetTime = now + LOGIN_LOCKOUT_MS;
+  }
+  const locked = entry.count >= LOGIN_MAX_ATTEMPTS;
+  return {
+    locked,
+    retryAfter: Math.ceil((entry.resetTime - now) / 1000),
+    remaining: Math.max(0, LOGIN_MAX_ATTEMPTS - entry.count),
+  };
+}
+
+export function clearLoginCounter(ip: string): void {
+  store.delete(LOGIN_PREFIX + ip);
+}

← 244b86c initial scaffold (2026-05-06 overnight session)  ·  back to Patty  ·  patty: add /pulse — Petition Pulse public visualization (CNC ca02103 →