← 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
M app/api/auth/login/route.tsA lib/rate-limit.ts
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 →