fix: Normalize IP addresses to avoid validation errors (#12500)

* fix: Normalize IP addresses to avoid validation errors on audit columns

Koa's `ctx.request.ip` can yield values that fail Sequelize's `isIP`
validation (X-Forwarded-For chains, IPv6 zone identifiers, "unknown"
from misconfigured proxies). This drops the IP metadata silently
instead of raising a 500 on Event/User writes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: Cover IP normalization on User setters

Reviewer feedback. Also switches the column-options `set` to TypeScript
get/set accessors — the original approach was shadowed by the class
field declaration and never actually fired, which the new tests would
have caught.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-05-27 22:52:05 -04:00
committed by GitHub
parent 09e99ac98d
commit 76a3ba4e83
5 changed files with 108 additions and 8 deletions
+38
View File
@@ -0,0 +1,38 @@
import { normalizeIp } from "./ip";
describe("normalizeIp", () => {
it("returns null for nullish or empty input", () => {
expect(normalizeIp(null)).toBeNull();
expect(normalizeIp(undefined)).toBeNull();
expect(normalizeIp("")).toBeNull();
expect(normalizeIp(" ")).toBeNull();
});
it("returns valid IPv4 unchanged", () => {
expect(normalizeIp("192.168.1.1")).toBe("192.168.1.1");
});
it("returns valid IPv6 unchanged", () => {
expect(normalizeIp("2001:db8::1")).toBe("2001:db8::1");
});
it("strips ::ffff: IPv4-mapped IPv6 prefix", () => {
expect(normalizeIp("::ffff:127.0.0.1")).toBe("127.0.0.1");
});
it("strips IPv6 zone identifier", () => {
expect(normalizeIp("fe80::1%eth0")).toBe("fe80::1");
});
it("takes the first entry from an X-Forwarded-For chain", () => {
expect(normalizeIp("203.0.113.1, 70.41.3.18, 150.172.238.178")).toBe(
"203.0.113.1"
);
});
it("returns null for non-IP values", () => {
expect(normalizeIp("unknown")).toBeNull();
expect(normalizeIp("not-an-ip")).toBeNull();
expect(normalizeIp("999.999.999.999")).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
import net from "node:net";
/**
* Normalize an IP address string for storage in audit columns.
*
* Handles common upstream-proxy artifacts that would otherwise fail
* Sequelize's `isIP` validation: IPv4-mapped IPv6 prefixes, IPv6 zone
* identifiers, and `X-Forwarded-For` chains. Returns `null` for any
* value that is not a valid IPv4 or IPv6 address after normalization.
*
* @param value the raw IP string (e.g. from `ctx.request.ip`).
* @returns a valid IP string, or `null`.
*/
export function normalizeIp(value: string | null | undefined): string | null {
if (!value || typeof value !== "string") {
return null;
}
let ip = value.split(",")[0]?.trim() ?? "";
ip = ip.replace(/^::ffff:/i, "");
const zoneIndex = ip.indexOf("%");
if (zoneIndex !== -1) {
ip = ip.slice(0, zoneIndex);
}
return net.isIP(ip) !== 0 ? ip : null;
}