[object Object]

← back to Norma

security(A-prime): scope login brute-force limiter per (ip,username) to kill tenant-wide lockout DoS; prefer cf-connecting-ip/x-real-ip over forgeable XFF; stop CRON_SECRET falling back to AUTH_PASSWORD

e0ef0095bbaf8b445685437f0f1beddea957a2b0 · 2026-08-05 13:40:26 -0700 · Steve

Files touched

Diff

commit e0ef0095bbaf8b445685437f0f1beddea957a2b0
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 5 13:40:26 2026 -0700

    security(A-prime): scope login brute-force limiter per (ip,username) to kill tenant-wide lockout DoS; prefer cf-connecting-ip/x-real-ip over forgeable XFF; stop CRON_SECRET falling back to AUTH_PASSWORD
---
 app/api/auth/login/route.ts | 38 +++++++++++++++++++-------------------
 lib/cron-auth.ts            |  4 +++-
 lib/rate-limit.ts           | 10 +++++++---
 3 files changed, 29 insertions(+), 23 deletions(-)

diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 190a164..82e870f 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -12,8 +12,8 @@ import { query } from '@/lib/db';
  * Standard 401 for invalid credentials.
  * Records the failure against the IP's brute-force counter.
  */
-function invalidCredentials(ip: string): NextResponse {
-  recordLoginFailure(ip);
+function invalidCredentials(rlKey: string): NextResponse {
+  recordLoginFailure(rlKey);
   return NextResponse.json(
     { error: 'Invalid credentials' },
     { status: 401 },
@@ -23,19 +23,6 @@ function invalidCredentials(ip: string): NextResponse {
 export async function POST(request: NextRequest) {
   const ip = loginClientIp(request);
 
-  // Brute-force gate: 10 failed attempts / 15 min per IP, then 30-min lock.
-  // Only failures bump the counter — see recordLoginFailure() calls below.
-  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) },
-      },
-    );
-  }
-
   try {
     let body: unknown;
     try {
@@ -59,6 +46,19 @@ export async function POST(request: NextRequest) {
       );
     }
 
+    // Brute-force gate scoped per (client-ip, username): a flood against ONE
+    // account — or shared-IP / no-proxy traffic that would otherwise collapse to
+    // a single key — can no longer lock out every other user (tenant-wide-lockout
+    // DoS). 10 failed attempts / 15 min, then 30-min lock. Only failures bump it.
+    const rlKey = `${ip}:${String(username)}`;
+    const gate = checkLoginAttempt(rlKey);
+    if (gate.locked) {
+      return NextResponse.json(
+        { error: 'too many attempts', retryAfter: gate.retryAfter },
+        { status: 429, headers: { 'Retry-After': String(gate.retryAfter) } },
+      );
+    }
+
     // Query tier_credentials by username only — verify password in application code
     // NOTE: tier_credentials has no full_name / is_active / last_login_at columns
     // in this deployment (migration not applied; schema changes are forbidden).
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
     );
 
     if (result.rows.length === 0) {
-      return invalidCredentials(ip);
+      return invalidCredentials(rlKey);
     }
 
     const row = result.rows[0];
@@ -78,7 +78,7 @@ export async function POST(request: NextRequest) {
     // Verify password (supports both bcrypt and legacy SHA-256 hashes)
     const { match, needsRehash } = await verifyPassword(password, row.password_hash);
     if (!match) {
-      return invalidCredentials(ip);
+      return invalidCredentials(rlKey);
     }
 
     // Auto-upgrade SHA-256 hashes to bcrypt on successful login
@@ -98,8 +98,8 @@ export async function POST(request: NextRequest) {
       ).catch(() => { /* non-fatal */ });
     }
 
-    // Successful login — clear this IP's brute-force counter
-    clearLoginCounter(ip);
+    // Successful login — clear this (ip, username) brute-force counter
+    clearLoginCounter(rlKey);
 
     const token = createSession(row.username, row.role, row.org_id);
     const cookieValue = buildAuthCookie(token);
diff --git a/lib/cron-auth.ts b/lib/cron-auth.ts
index d9a7ce5..2a43fd9 100644
--- a/lib/cron-auth.ts
+++ b/lib/cron-auth.ts
@@ -5,7 +5,9 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { verifyAuth } from './auth';
 
-const CRON_SECRET = process.env.CRON_SECRET || process.env.AUTH_PASSWORD;
+// CRON_SECRET must be its OWN dedicated secret — do NOT fall back to the admin
+// login password (that conflation meant a leaked admin pw also unlocked cron).
+const CRON_SECRET = process.env.CRON_SECRET;
 const AUTH_USERNAME = process.env.AUTH_USERNAME || 'admin';
 const AUTH_PASSWORD = process.env.AUTH_PASSWORD;
 
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
index 19e8532..b561869 100644
--- a/lib/rate-limit.ts
+++ b/lib/rate-limit.ts
@@ -53,10 +53,13 @@ export function checkRateLimit(
 
   const now = Date.now();
 
-  // Derive IP from standard forwarding headers or fallback
+  // Derive IP, preferring headers a client cannot forge through our proxies
+  // (Cloudflare's cf-connecting-ip, then nginx's x-real-ip) over the
+  // client-controlled leftmost x-forwarded-for entry.
   const ip =
-    request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+    request.headers.get('cf-connecting-ip') ||
     request.headers.get('x-real-ip') ||
+    request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
     'unknown';
 
   const key = keyPrefix ? `${keyPrefix}:${ip}` : ip;
@@ -121,8 +124,9 @@ export interface LoginAttemptResult {
  */
 export function loginClientIp(request: NextRequest): string {
   return (
+    request.headers.get('cf-connecting-ip') ||          // Cloudflare real client (unforgeable through CF)
+    request.headers.get('x-real-ip') ||                 // set by our nginx ($remote_addr)
     request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
-    request.headers.get('x-real-ip') ||
     'unknown'
   );
 }

← f63804e security: fix cycle-1 defects — add requireRole to 5 social  ·  back to Norma  ·  security(cycle-2): pin retired gemini-2.0-flash -> 2.5-flash e390f1a →