From 59dccdd9e1c603dff55b02b4b0ff22c7aeb61bbc Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Mon, 24 Aug 2026 16:24:08 -0400 Subject: [PATCH] fix(security): sanitize agent-card topology, anti-spoof login rate-limit peer IP, and add 429 Retry-After (#S1 #S2 #S4) (#11418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado em lote combinado (batch-0824g) contra o tip de release/v3.8.50: typecheck:core limpo, gates estáticos OK, 62/62 testes focados passando (S1/S2/S4, tests/unit/security-s1-s2-s4.test.ts, 9/9). Boa integração com o padrão já existente de peer IP stamped por HMAC (resolveStampedPeer/OMNIROUTE_PEER_STAMP_TOKEN) — reusa em vez de reimplementar, e o header confiável só é honrado quando o stamp token está configurado. S2 remove corretamente a disclosure de topologia hardcoded do agent-card. Obrigado pela contribuição! --- .gitignore | 1 + src/app/.well-known/agent-card.json/route.ts | 12 +- src/app/.well-known/agent.json/route.ts | 8 +- src/app/api/auth/login/route.ts | 17 +- src/lib/wellKnown.ts | 11 + src/server/authz/headers.ts | 11 + src/server/authz/pipeline.ts | 11 + tests/unit/security-s1-s2-s4.test.ts | 353 +++++++++++++++++++ 8 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 src/lib/wellKnown.ts create mode 100644 tests/unit/security-s1-s2-s4.test.ts diff --git a/.gitignore b/.gitignore index 08bceafd36..07545f2a37 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ _tasks/ .agents/** .claude/** .gemini/** +.code-forge/** .config/** .data/** .logs/** diff --git a/src/app/.well-known/agent-card.json/route.ts b/src/app/.well-known/agent-card.json/route.ts index 031c4081c6..a041e47d6f 100644 --- a/src/app/.well-known/agent-card.json/route.ts +++ b/src/app/.well-known/agent-card.json/route.ts @@ -11,19 +11,21 @@ */ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { getFleetSkills } from "@/lib/conductor/fleetSkills"; +import { getBaseUrl } from "@/lib/wellKnown"; const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1"; -const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; /** * GET /.well-known/agent-card.json * * Returns the OmniRoute Agent Card (A2A v1.0). */ -export async function GET() { +export async function GET(request: NextRequest) { const fleetSkills = await getFleetSkills(); + const baseUrl = getBaseUrl(request); const agentCard = { name: "OmniRoute AI Gateway", @@ -31,16 +33,16 @@ export async function GET() { "Intelligent AI routing gateway with 36+ providers, smart fallback, quota tracking, " + "format translation, and auto-managed combos. Routes AI requests to the optimal " + "provider based on cost, latency, quota availability, and task requirements.", - url: `${BASE_URL}/a2a`, + url: `${baseUrl}/a2a`, version: PACKAGE_VERSION, supportedInterfaces: [ { - url: `${BASE_URL}/a2a`, + url: `${baseUrl}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0", }, { - url: `${BASE_URL}/a2a`, + url: `${baseUrl}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "0.3", }, diff --git a/src/app/.well-known/agent.json/route.ts b/src/app/.well-known/agent.json/route.ts index be16ffa62d..e208888b05 100644 --- a/src/app/.well-known/agent.json/route.ts +++ b/src/app/.well-known/agent.json/route.ts @@ -9,11 +9,12 @@ */ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { getFleetSkills } from "@/lib/conductor/fleetSkills"; +import { getBaseUrl } from "@/lib/wellKnown"; const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1"; -const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; /** * GET /.well-known/agent.json @@ -21,17 +22,18 @@ const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; * Returns the OmniRoute Agent Card that describes this gateway's * capabilities as an A2A agent. */ -export async function GET() { +export async function GET(request: NextRequest) { // Conductor PRD RF2: fleet skills from the OmniConductor hub (cached ~60s; [] when // the hub is unset/offline — the card stays valid without the fleet section). const fleetSkills = await getFleetSkills(); + const baseUrl = getBaseUrl(request); const agentCard = { name: "OmniRoute AI 网关", description: "智能 AI 路由网关,支持 36+ 个提供者、智能回退、配额跟踪、" + "格式转换和自动管理组合。根据成本、延迟、配额可用性" + "和任务要求将 AI 请求路由到最优提供者。", - url: `${BASE_URL}/a2a`, + url: `${baseUrl}/a2a`, version: PACKAGE_VERSION, capabilities: { streaming: true, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8855c1a8c8..c4142a4768 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { classifyIpScope } from "@/lib/ipUtils"; import { getCachedSettings } from "@/lib/db/settings"; @@ -13,6 +14,7 @@ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { loginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard"; +import { AUTHZ_HEADER_TRUSTED_PEER_IP } from "@/server/authz/headers"; // SECURITY: No hardcoded fallback — JWT_SECRET must be configured. if (!process.env.JWT_SECRET) { @@ -28,7 +30,7 @@ export const authRouteInternals = { getCookieStore: cookies, }; -export async function POST(request) { +export async function POST(request: NextRequest) { const auditContext = getAuditRequestContext(request); try { @@ -75,7 +77,10 @@ export async function POST(request) { return NextResponse.json({ error: "Invalid password payload" }, { status: 400 }); } const settings = await getCachedSettings(); - const clientIp = auditContext.ipAddress || null; + const trustedPeerIp = process.env.OMNIROUTE_PEER_STAMP_TOKEN + ? request.headers.get(AUTHZ_HEADER_TRUSTED_PEER_IP) + : null; + const clientIp = trustedPeerIp || auditContext.ipAddress || null; const oidcDisabledPassword = settings.oidcEnabled === true && (settings.oidcDisablePasswordLogin === true || @@ -118,9 +123,7 @@ export async function POST(request) { { error: "Too many failed attempts. Try again later." }, { status: 429, - headers: guardCheck.retryAfterSeconds - ? { "Retry-After": String(guardCheck.retryAfterSeconds) } - : {}, + headers: { "Retry-After": String(guardCheck.retryAfterSeconds || 60) }, } ); } @@ -220,9 +223,7 @@ export async function POST(request) { { error: "Too many failed attempts. Try again later." }, { status: 429, - headers: failureDecision.retryAfterSeconds - ? { "Retry-After": String(failureDecision.retryAfterSeconds) } - : {}, + headers: { "Retry-After": String(failureDecision.retryAfterSeconds || 60) }, } ); } diff --git a/src/lib/wellKnown.ts b/src/lib/wellKnown.ts new file mode 100644 index 0000000000..627a41d88c --- /dev/null +++ b/src/lib/wellKnown.ts @@ -0,0 +1,11 @@ +import type { NextRequest } from "next/server"; + +/** + * Derive the base URL for A2A agent card endpoints. + * Prefers OMNIROUTE_BASE_URL env var for admin override; falls back to the + * request's dynamic origin so the gateway works behind any hostname without + * hardcoded localhost:20128 (S2 security fix). + */ +export function getBaseUrl(request: NextRequest): string { + return process.env.OMNIROUTE_BASE_URL || request.nextUrl.origin; +} \ No newline at end of file diff --git a/src/server/authz/headers.ts b/src/server/authz/headers.ts index 002e679739..399be96eab 100644 --- a/src/server/authz/headers.ts +++ b/src/server/authz/headers.ts @@ -62,6 +62,16 @@ export const VIA_PROXY_HEADER = "x-omniroute-via-proxy"; */ export const AUTHZ_HEADER_PEER_LOCALITY = "x-omniroute-peer-locality"; +/** + * The resolved real peer IP, stamped by the pipeline AFTER verifying the + * token-stamped PEER_IP_HEADER. This is the trusted, non-spoofable IP that + * route handlers (e.g. login rate-limit key) should use instead of re-deriving + * from X-Forwarded-For / X-Real-IP. Set only when the stamp token is configured + * and the HMAC signature validates; absent when the stamp is not in use. + * Stripped from incoming requests like all other trusted headers. + */ +export const AUTHZ_HEADER_TRUSTED_PEER_IP = "x-omniroute-trusted-peer-ip"; + /** * Headers the pipeline must NEVER trust on incoming requests. They are * stripped before route classification to prevent header-spoofing attacks. @@ -73,4 +83,5 @@ export const AUTHZ_TRUSTED_HEADERS: ReadonlyArray = [ AUTHZ_HEADER_AUTH_LABEL, AUTHZ_HEADER_AUTH_SCOPES, AUTHZ_HEADER_PEER_LOCALITY, + AUTHZ_HEADER_TRUSTED_PEER_IP, ]; diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index d8dacf376d..9ef7fed2fd 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -25,6 +25,7 @@ import { AUTHZ_HEADER_PEER_LOCALITY, AUTHZ_HEADER_REQUEST_ID, AUTHZ_HEADER_ROUTE_CLASS, + AUTHZ_HEADER_TRUSTED_PEER_IP, AUTHZ_TRUSTED_HEADERS, CLI_TOKEN_HEADER, PEER_IP_HEADER, @@ -332,6 +333,16 @@ export async function runAuthzPipeline( process.env.OMNIROUTE_PEER_STAMP_TOKEN ); requestHeaders.set(AUTHZ_HEADER_PEER_LOCALITY, peerLocality); + // Stamp the resolved, non-spoofable peer IP for route handlers that need + // the real client IP (e.g. login rate-limit key). Only set when the stamp + // token is configured and the HMAC signature validates; absent otherwise. + const trustedPeerIp = resolveStampedPeer( + request.headers.get(PEER_IP_HEADER), + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + if (trustedPeerIp) { + requestHeaders.set(AUTHZ_HEADER_TRUSTED_PEER_IP, trustedPeerIp); + } // Local CLI-token auth is decided centrally above. Preserve that trusted // decision for route-level requireManagementAuth without forwarding the // machine token itself: custom client auth headers are stripped before the diff --git a/tests/unit/security-s1-s2-s4.test.ts b/tests/unit/security-s1-s2-s4.test.ts new file mode 100644 index 0000000000..74dc6152af --- /dev/null +++ b/tests/unit/security-s1-s2-s4.test.ts @@ -0,0 +1,353 @@ +/** + * Security compliance tickets S1, S2, S4 — unit tests. + * + * S1 — Login rate-limit key uses anti-spoofed peer IP (x-omniroute-trusted-peer-ip) + * S2 — A2A agent-card topology sanitisation (no hardcoded localhost:20128) + * S4 — 429 Retry-After header always present on lockout responses + */ +import { describe, it, beforeEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { NextRequest } from "next/server"; + +// ── S2: agent-card route tests (no heavy mocking needed) ────────────── + +describe("S2 — agent-card topology sanitisation", () => { + const BASE_URL_SAVED = process.env.OMNIROUTE_BASE_URL; + + beforeEach(() => { + delete process.env.OMNIROUTE_BASE_URL; + }); + + after(() => { + if (BASE_URL_SAVED !== undefined) { + process.env.OMNIROUTE_BASE_URL = BASE_URL_SAVED; + } else { + delete process.env.OMNIROUTE_BASE_URL; + } + }); + + it("agent-card.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => { + const mod = await import("../../src/app/.well-known/agent-card.json/route.ts"); + const request = new Request("https://gateway.example.com/.well-known/agent-card.json") as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("https://gateway.example.com/.well-known/agent-card.json"), + configurable: true, + }); + + const res = await mod.GET(request); + assert.equal(res.status, 200); + const card = (await res.json()) as { url?: string; supportedInterfaces?: { url?: string }[] }; + assert.ok(card.url, "card must have a url"); + assert.ok(card.url.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`); + if (card.supportedInterfaces && card.supportedInterfaces.length > 0) { + assert.ok( + card.supportedInterfaces[0].url?.startsWith("https://gateway.example.com"), + `interface URL should use dynamic origin, got ${card.supportedInterfaces[0].url}` + ); + } + }); + + it("agent-card.json uses OMNIROUTE_BASE_URL when set", async () => { + process.env.OMNIROUTE_BASE_URL = "https://custom.example.com"; + const mod = await import("../../src/app/.well-known/agent-card.json/route.ts"); + const request = new Request("http://localhost:20128/.well-known/agent-card.json") as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/.well-known/agent-card.json"), + configurable: true, + }); + + const res = await mod.GET(request); + assert.equal(res.status, 200); + const card = (await res.json()) as { url?: string }; + assert.ok(card.url?.startsWith("https://custom.example.com"), `expected custom.example.com, got ${card.url}`); + }); + + it("agent.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => { + const mod = await import("../../src/app/.well-known/agent.json/route.ts"); + const request = new Request("https://gateway.example.com/.well-known/agent.json") as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("https://gateway.example.com/.well-known/agent.json"), + configurable: true, + }); + + const res = await mod.GET(request); + assert.equal(res.status, 200); + const card = (await res.json()) as { url?: string }; + assert.ok(card.url?.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`); + }); +}); + +// ── Login guard module (loaded once for S4 tests) ───────────────────── +const loginGuardMod = await import("../../src/server/auth/loginGuard"); + +// ── S4: login guard Retry-After tests ───────────────────────────────── + +describe("S4 — 429 Retry-After header", () => { + const { + checkLoginGuard, + recordLoginFailure, + resetLoginGuardForTests, + LOGIN_GUARD_TUNABLES, + } = loginGuardMod; + + beforeEach(() => { + resetLoginGuardForTests(); + }); + + it("checkLoginGuard returns retryAfterSeconds when locked", () => { + const ip = "10.0.0.99"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + recordLoginFailure(ip, { enabled: true }); + } + const decision = checkLoginGuard(ip, { enabled: true }); + assert.equal(decision.allowed, false); + assert.ok(typeof decision.retryAfterSeconds === "number" && decision.retryAfterSeconds > 0, + `retryAfterSeconds should be > 0, got ${decision.retryAfterSeconds}`); + }); + + it("recordLoginFailure returns retryAfterSeconds on threshold hit", () => { + const ip = "10.0.0.100"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + const dec = recordLoginFailure(ip, { enabled: true }); + if (i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD - 1) { + assert.equal(dec.allowed, true, `attempt #${i + 1} should still be allowed`); + } else { + assert.equal(dec.allowed, false, `attempt #${i + 1} (threshold) should be locked`); + assert.ok(typeof dec.retryAfterSeconds === "number" && dec.retryAfterSeconds > 0, + `retryAfterSeconds should be > 0 on threshold hit, got ${dec.retryAfterSeconds}`); + } + } + }); + + it("both guard functions provide retryAfterSeconds for the response header", () => { + const ip = "10.0.0.101"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + recordLoginFailure(ip, { enabled: true }); + } + const guardDec = checkLoginGuard(ip, { enabled: true }); + assert.equal(guardDec.allowed, false); + const headerValue = String(guardDec.retryAfterSeconds || 60); + assert.ok(/^\d+$/.test(headerValue), `Retry-After should be an integer string, got ${headerValue}`); + assert.ok(Number.parseInt(headerValue, 10) > 0, "Retry-After should be positive"); + + resetLoginGuardForTests(); + const ip2 = "10.0.0.102"; + let failureDec: ReturnType | undefined; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + failureDec = recordLoginFailure(ip2, { enabled: true }); + } + assert.equal(failureDec!.allowed, false); + const headerValue2 = String(failureDec!.retryAfterSeconds || 60); + assert.ok(/^\d+$/.test(headerValue2), `Retry-After should be an integer string, got ${headerValue2}`); + assert.ok(Number.parseInt(headerValue2, 10) > 0, "Retry-After should be positive"); + }); +}); + +// ── S1: login route uses trusted peer IP for rate-limit key ─────────── +// Integration test: sets up the real DB, management password, and settings, +// then calls the login route POST function to verify the clientIp derivation. +// The route uses: clientIp = request.headers.get("x-omniroute-trusted-peer-ip") || auditContext.ipAddress || null + +describe("S1 — login rate-limit key uses anti-spoofed peer IP", () => { + const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-security-s1-s2-s4-")); + const JWT_SAVED = process.env.JWT_SECRET; + const INITIAL_PASSWORD_SAVED = process.env.INITIAL_PASSWORD; + + let loginRoute: typeof import("../../src/app/api/auth/login/route.ts"); + let loginGuardModRef: typeof import("../../src/server/auth/loginGuard"); + let settingsDb: typeof import("../../src/lib/db/settings.ts"); + + beforeEach(async () => { + // Reset env + process.env.DATA_DIR = TEST_DATA_DIR; + process.env.JWT_SECRET = "test-jwt-secret-for-s1-s2-s4-tests"; + // Use a bcrypt hash of "test-password" as the initial password so the + // login route already has a valid hash in the DB settings. + process.env.INITIAL_PASSWORD = "test-password"; + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + delete process.env.OMNIROUTE_BASE_URL; + + // Create data dir + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + + // Reset DB and set up settings + const core = await import("../../src/lib/db/core.ts"); + core.resetDbInstance(); + settingsDb = await import("../../src/lib/db/settings.ts"); + await settingsDb.updateSettings({ bruteForceProtection: true }); + + // Import login guard and reset state + loginGuardModRef = await import("../../src/server/auth/loginGuard"); + loginGuardModRef.resetLoginGuardForTests(); + + // Now import the login route + loginRoute = await import("../../src/app/api/auth/login/route.ts"); + }); + + after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (JWT_SAVED !== undefined) { + process.env.JWT_SECRET = JWT_SAVED; + } else { + delete process.env.JWT_SECRET; + } + // Restore INITIAL_PASSWORD + if (INITIAL_PASSWORD_SAVED !== undefined) { + process.env.INITIAL_PASSWORD = INITIAL_PASSWORD_SAVED; + } else { + delete process.env.INITIAL_PASSWORD; + } + }); + + it("uses x-omniroute-trusted-peer-ip for rate-limit key when header is present", async () => { + // The login route derives clientIp from the trusted peer IP header. + // We make multiple requests with the same trusted peer IP but different + // forged XFF headers to verify they share the same rate-limit bucket. + // + // The route only trusts the header when OMNIROUTE_PEER_STAMP_TOKEN is set. + // Without the token, spoofed headers are rejected (tested separately below). + + process.env.OMNIROUTE_PEER_STAMP_TOKEN = "test-stamp-token"; + + const TRUSTED_IP = "203.0.113.42"; + const FORGED_XFF = "192.168.1.1, 10.0.0.1"; + + // Make enough requests to trigger the rate limit + for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) { + const request = new Request("http://localhost:20128/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-omniroute-trusted-peer-ip": TRUSTED_IP, + "x-forwarded-for": i === 0 ? FORGED_XFF : `10.0.0.${i}, 172.16.0.1`, + }, + body: JSON.stringify({ password: "wrong-password" }), + }) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/api/auth/login"), + configurable: true, + }); + + // The email is not checked in the login route — only password matters + // Let's also try the correct password to make sure login works + const res = await loginRoute.POST(request); + if (res.status === 429) { + // Locked out — rate-limit key is tied to the trusted peer IP, not XFF + const retryAfter = res.headers.get("Retry-After"); + assert.ok(retryAfter !== null, "429 response must include Retry-After header"); + assert.ok(/^\d+$/.test(retryAfter!), `Retry-After should be a positive integer, got ${retryAfter}`); + return; + } + } + assert.fail("Expected at least one 429 response after threshold failed attempts with the same trusted peer IP"); + }); + + it("ignores spoofed x-omniroute-trusted-peer-ip when OMNIROUTE_PEER_STAMP_TOKEN is not set", async () => { + loginGuardModRef.resetLoginGuardForTests(); + + // OMNIROUTE_PEER_STAMP_TOKEN is already deleted in beforeEach. + // The route should NOT trust the spoofed header and fall back to + // auditContext.ipAddress (derived from X-Forwarded-For). + // + // TDD: each iteration uses a DIFFERENT spoofed IP. With the bug + // (unconditional trust), each request goes to a different rate-limit + // bucket — no bucket reaches the threshold → test FAILS (RED). + // With the fix (gate on OMNIROUTE_PEER_STAMP_TOKEN), all requests + // share the REAL_IP bucket → threshold hit → test PASSES (GREEN). + + const REAL_IP = "10.0.0.200"; + + for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) { + const SPOOFED_IP = `203.0.113.${i}`; + const request = new Request("http://localhost:20128/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-omniroute-trusted-peer-ip": SPOOFED_IP, + "x-forwarded-for": REAL_IP, + }, + body: JSON.stringify({ password: "wrong-password" }), + }) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/api/auth/login"), + configurable: true, + }); + + const res = await loginRoute.POST(request); + if (res.status === 429) { + // Locked out — rate-limit key is tied to REAL_IP (XFF), not the spoofed header + const retryAfter = res.headers.get("Retry-After"); + assert.ok(retryAfter !== null, "429 response must include Retry-After header"); + return; + } + } + assert.fail("Expected 429 after threshold failures — spoofed header should not bypass rate-limit"); + }); + + it("falls back to auditContext.ipAddress when trusted peer IP header is absent", async () => { + loginGuardModRef.resetLoginGuardForTests(); + + // Without the trusted peer IP header, the rate-limit key falls back to + // auditContext.ipAddress which reads from X-Forwarded-For / X-Real-IP. + // We set XFF to a specific IP and verify that requests with that IP get + // rate-limited, while requests with a different IP do not. + + const REQUEST_IP = "10.0.0.99"; + + for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) { + const request = new Request("http://localhost:20128/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-for": REQUEST_IP, + }, + body: JSON.stringify({ password: "wrong-password" }), + }) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/api/auth/login"), + configurable: true, + }); + + const res = await loginRoute.POST(request); + if (res.status === 429) { + // Locked out — rate-limit key is tied to the XFF-derived IP + const retryAfter = res.headers.get("Retry-After"); + assert.ok(retryAfter !== null, "429 response must include Retry-After header"); + assert.ok(Number.parseInt(retryAfter!, 10) > 0, `Retry-After should be > 0, got ${retryAfter}`); + return; + } + } + assert.fail("Expected 429 after threshold failures from the same IP"); + }); + + it("S4 — 429 response includes Retry-After header in login route", async () => { + loginGuardModRef.resetLoginGuardForTests(); + + for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) { + const request = new Request("http://localhost:20128/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-omniroute-trusted-peer-ip": "203.0.113.99", + }, + body: JSON.stringify({ password: "wrong-password" }), + }) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/api/auth/login"), + configurable: true, + }); + + const res = await loginRoute.POST(request); + if (res.status === 429) { + const retryAfter = res.headers.get("Retry-After"); + assert.ok(retryAfter !== null, "429 response must include Retry-After header"); + assert.ok(Number.parseInt(retryAfter!, 10) > 0, `Retry-After should be > 0, got ${retryAfter}`); + return; + } + } + assert.fail("Expected at least one 429 response after threshold failed attempts"); + }); +}); \ No newline at end of file