diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 720587a2d2..a0c1504560 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { classifyIpScope } from "@/lib/ipUtils"; import { getCachedSettings } from "@/lib/localDb"; import { SignJWT } from "jose"; import { cookies } from "next/headers"; @@ -168,6 +169,11 @@ export async function POST(request) { const failureDecision = recordLoginFailure(clientIp, { enabled: bruteForceEnabled }); + // #8336: tag the origin scope so the audit view can distinguish a mistyped + // password from the host itself / the LAN (loopback / private) from a + // genuinely external attempt, instead of every failure reading as intrusion. + const sourceScope = classifyIpScope(auditContext.ipAddress); + logAuditEvent({ action: "auth.login.failed", actor: "anonymous", @@ -176,7 +182,12 @@ export async function POST(request) { status: "failed", ipAddress: auditContext.ipAddress || undefined, requestId: auditContext.requestId, - metadata: { reason: "invalid_password", lockedOut: failureDecision.allowed === false }, + metadata: { + reason: "invalid_password", + lockedOut: failureDecision.allowed === false, + sourceScope, + internalOrigin: sourceScope === "loopback" || sourceScope === "private", + }, }); if (!failureDecision.allowed) { diff --git a/src/lib/ipUtils.ts b/src/lib/ipUtils.ts index 3bf92d3127..66e016555e 100644 --- a/src/lib/ipUtils.ts +++ b/src/lib/ipUtils.ts @@ -52,6 +52,60 @@ function isLoopbackPeer(addr: string | undefined): boolean { return ip.startsWith("127."); } +export type IpScope = "loopback" | "private" | "public" | "unknown"; + +/** + * Whether an IPv4/IPv6 address falls inside a private (RFC 1918 / RFC 4193) or + * link-local range. Uses bounded string-prefix checks (no variable-length regex) + * to stay ReDoS-safe. Callers must pass an already-normalized, validated IP. + */ +function isPrivateIp(ip: string): boolean { + // IPv4 private / link-local ranges. + if (ip.startsWith("10.")) return true; + if (ip.startsWith("192.168.")) return true; + if (ip.startsWith("169.254.")) return true; // link-local (APIPA) + if (ip.startsWith("172.")) { + // 172.16.0.0 – 172.31.255.255 (the /12 block). + const secondOctet = Number.parseInt(ip.split(".")[1] ?? "", 10); + if (secondOctet >= 16 && secondOctet <= 31) return true; + } + // IPv6 unique-local (fc00::/7 → fc/fd) and link-local (fe80::/10). + const lower = ip.toLowerCase(); + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + if ( + lower.startsWith("fe8") || + lower.startsWith("fe9") || + lower.startsWith("fea") || + lower.startsWith("feb") + ) { + return true; + } + return false; +} + +/** + * Classify an address by network scope: `loopback` (the host itself), + * `private` (LAN / RFC 1918 / ULA / link-local), `public` (routable), or + * `unknown` (empty / non-IP string like "unknown"). + * + * Used to distinguish audit events — notably failed dashboard logins + * (`auth.login.failed`) — that originate from the box itself or the local + * network from genuinely external ones, so an internal mistyped password or a + * browser-autofill replay does not read as an external intrusion attempt. + * + * Ref: issue #8336. + */ +export function classifyIpScope(addr: string | null | undefined): IpScope { + const ip = normalizePeer(addr ?? undefined); + if (!ip) return "unknown"; + if (isLoopbackPeer(ip)) return "loopback"; + // isIP returns 0 for non-IP strings ("unknown", hostnames); only classify + // real addresses beyond loopback so hostnames never resolve to private. + if (isIP(ip) === 0) return "unknown"; + if (isPrivateIp(ip)) return "private"; + return "public"; +} + /** * Extract client IP from a Request or NextRequest object. * diff --git a/tests/unit/8336-audit-loopback-login.test.ts b/tests/unit/8336-audit-loopback-login.test.ts new file mode 100644 index 0000000000..2159bac0ba --- /dev/null +++ b/tests/unit/8336-audit-loopback-login.test.ts @@ -0,0 +1,119 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Regression test for issue #8336 — failed dashboard logins originating from + * internal/loopback IPs read as external intrusion attempts in the Audit Log. + * + * The events themselves are real (a submitted, non-empty password that failed + * verification) and the recorded IP is accurate. What was missing was a way to + * DISTINGUISH a loopback/private-origin failure (someone browsing + * http://localhost and mistyping, a browser autofill replay) from a genuinely + * external one. This test pins the two-part fix: + * + * 1. `classifyIpScope()` in `@/lib/ipUtils` classifies an address by network + * scope (loopback / private / public / unknown). + * 2. The `auth.login.failed` audit event carries `sourceScope` + + * `internalOrigin` metadata so the audit view can label internal-origin + * failures distinctly. + */ + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-8336-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-8336"; + +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +const ipUtils = await import("../../src/lib/ipUtils.ts"); +const core = await import("../../src/lib/db/core.ts"); +const compliance = await import("../../src/lib/compliance/index.ts"); +const loginRoute = await import("../../src/app/api/auth/login/route.ts"); + +const originalGetCookieStore = loginRoute.authRouteInternals.getCookieStore; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + process.env.INITIAL_PASSWORD = "correct-secret-8336"; +} + +test.beforeEach(async () => { + await resetStorage(); + loginRoute.authRouteInternals.getCookieStore = async () => ({ set() {} }); +}); + +test.afterEach(() => { + loginRoute.authRouteInternals.getCookieStore = originalGetCookieStore; +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_INITIAL_PASSWORD === undefined) { + delete process.env.INITIAL_PASSWORD; + } else { + process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; + } +}); + +test("classifyIpScope distinguishes loopback / private / public / unknown", () => { + assert.equal(ipUtils.classifyIpScope("127.0.0.1"), "loopback"); + assert.equal(ipUtils.classifyIpScope("::1"), "loopback"); + assert.equal(ipUtils.classifyIpScope("::ffff:127.0.0.1"), "loopback"); + assert.equal(ipUtils.classifyIpScope("10.1.2.3"), "private"); + assert.equal(ipUtils.classifyIpScope("192.168.0.5"), "private"); + assert.equal(ipUtils.classifyIpScope("172.16.4.4"), "private"); + assert.equal(ipUtils.classifyIpScope("172.31.255.1"), "private"); + assert.equal(ipUtils.classifyIpScope("fd00::1"), "private"); + assert.equal(ipUtils.classifyIpScope("203.0.113.50"), "public"); + assert.equal(ipUtils.classifyIpScope("8.8.8.8"), "public"); + // 172.32 is outside the private /12 block + assert.equal(ipUtils.classifyIpScope("172.32.0.1"), "public"); + assert.equal(ipUtils.classifyIpScope("unknown"), "unknown"); + assert.equal(ipUtils.classifyIpScope(""), "unknown"); + assert.equal(ipUtils.classifyIpScope(null), "unknown"); +}); + +async function postWrongPassword(forwardedFor: string) { + return loginRoute.POST( + new Request("http://localhost/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + // No socket peer in this fetch-style path → forwarding headers are + // trusted, so this stands in for the recorded client IP. + "x-forwarded-for": forwardedFor, + }, + body: JSON.stringify({ password: "wrong-password" }), + }) + ); +} + +test("auth.login.failed from a loopback IP is flagged internalOrigin", async () => { + const response = await postWrongPassword("127.0.0.1"); + assert.equal(response.status, 401); + + const [entry] = compliance.getAuditLog({ action: "auth.login.failed", limit: 1 }); + assert.ok(entry, "expected an auth.login.failed audit entry"); + assert.equal(entry.ip_address, "127.0.0.1"); + const metadata = entry.metadata as Record; + assert.equal(metadata.reason, "invalid_password"); + assert.equal(metadata.sourceScope, "loopback"); + assert.equal(metadata.internalOrigin, true); +}); + +test("auth.login.failed from a public IP is NOT flagged internalOrigin", async () => { + const response = await postWrongPassword("203.0.113.77"); + assert.equal(response.status, 401); + + const [entry] = compliance.getAuditLog({ action: "auth.login.failed", limit: 1 }); + assert.ok(entry, "expected an auth.login.failed audit entry"); + assert.equal(entry.ip_address, "203.0.113.77"); + const metadata = entry.metadata as Record; + assert.equal(metadata.sourceScope, "public"); + assert.equal(metadata.internalOrigin, false); +}); diff --git a/tests/unit/admin-audit-events.test.ts b/tests/unit/admin-audit-events.test.ts index cf8169fba0..b8aa06710b 100644 --- a/tests/unit/admin-audit-events.test.ts +++ b/tests/unit/admin-audit-events.test.ts @@ -121,7 +121,14 @@ test("auth login route records failed password attempts", async () => { assert.equal(event.actor, "anonymous"); assert.equal(event.status, "failed"); assert.equal(event.requestId, "req-auth-failed"); - assert.deepEqual(event.metadata, { reason: "invalid_password", lockedOut: false }); + // The request above carries a public x-forwarded-for (198.51.100.22), so the + // origin tagging added here must classify it as public / non-internal. + assert.deepEqual(event.metadata, { + reason: "invalid_password", + lockedOut: false, + sourceScope: "public", + internalOrigin: false, + }); }); test("provider create/update/delete routes emit sanitized credential audit events", async () => {