mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
119
tests/unit/8336-audit-loopback-login.test.ts
Normal file
119
tests/unit/8336-audit-loopback-login.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user