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