fix(auth): tag internal/loopback-origin failed logins in the audit log (#8606)

* fix(auth): tag internal/loopback-origin failed logins in the audit log

Failed dashboard logins (`auth.login.failed`) are emitted only after a
submitted, non-empty password fails verification, and the recorded IP is
accurate. On a single-process deployment with no reverse proxy, a loopback /
private source IP therefore means the attempt genuinely originated on the box
or the LAN (someone browsing http://localhost and mistyping, or a browser
autofill replaying a stale password) — but the Audit Log had no way to
distinguish those from an external intrusion attempt, so they read as
suspicious noise.

Add `classifyIpScope()` to `ipUtils` (loopback / private / public / unknown,
using bounded string-prefix checks — ReDoS-safe) and stamp `sourceScope` +
`internalOrigin` onto the `auth.login.failed` audit metadata so the audit view
can label internal-origin failures distinctly. No change to which events are
written or to IP attribution.

Closes #8336

* test(auth): assert the new origin tags on the failed-login audit event

This PR tags failed logins with sourceScope/internalOrigin, but the
pre-existing admin-audit-events assertion still deepEqual'd the old
two-field metadata shape and broke. The request under test carries a
public x-forwarded-for, so the expected tags are sourceScope: "public"
and internalOrigin: false — the assertion stays strict, it just covers
the fields this PR introduces.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Prudhvi Vuda
2026-07-27 18:07:33 -04:00
committed by GitHub
parent 20aa796d97
commit ea678daad6
4 changed files with 193 additions and 2 deletions

View File

@@ -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) {

View File

@@ -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.
*

View File

@@ -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<string, unknown>;
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<string, unknown>;
assert.equal(metadata.sourceScope, "public");
assert.equal(metadata.internalOrigin, false);
});

View File

@@ -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 () => {