Files
Pranjul Chaurasiya 038381aeca fix: trim whitespace in parseIpFromHeaders to prevent IP banlist bypass (#29857)
* fix: trim whitespace in parseIpFromHeaders to prevent IP banlist bypass

Fixes #29851

parseIpFromHeaders did not trim whitespace after splitting on comma
(or from array elements), so a padded x-forwarded-for header like
' 1.2.3.4 , 5.6.7.8' would fail an exact-match lookup against
IP_BANLIST, allowing a banned IP to bypass the check.

Added regression tests covering padded string, array, and an
end-to-end isIpInBanlist test reproducing the reported bypass.

* fix: address CodeRabbit review feedback

- Guard against undefined rawIp when parsing an empty header array
- Delete IP_BANLIST env var in test cleanup instead of assigning undefined
- Add regression test for empty array input to parseIpFromHeaders

* test: add regression test for tab/whitespace trimming in parseIpFromHeaders
2026-07-31 20:49:53 +00:00

160 lines
5.3 KiB
TypeScript

import type { NextApiRequest } from "next";
import { afterEach, describe, expect, it } from "vitest";
import getIP, { isIpInBanlist, parseIpFromHeaders } from "./getIP";
function buildRequest(headers: Record<string, string>): Request {
return new Request("https://example.com", { headers });
}
function buildNextApiRequest(headers: Record<string, string | string[]>): NextApiRequest {
return { headers } as unknown as NextApiRequest;
}
describe("parseIpFromHeaders", () => {
it("returns the first IP from a comma-separated string", () => {
expect(parseIpFromHeaders("1.2.3.4, 5.6.7.8")).toBe("1.2.3.4");
});
it("returns the single IP when there is no comma", () => {
expect(parseIpFromHeaders("1.2.3.4")).toBe("1.2.3.4");
});
it("returns the first element when given an array", () => {
expect(parseIpFromHeaders(["1.2.3.4", "5.6.7.8"])).toBe("1.2.3.4");
});
it("trims leading/trailing whitespace from a padded string value", () => {
expect(parseIpFromHeaders(" 1.2.3.4 , 5.6.7.8")).toBe("1.2.3.4");
});
it("trims whitespace after splitting on comma", () => {
expect(parseIpFromHeaders("1.2.3.4 , 5.6.7.8")).toBe("1.2.3.4");
});
it("trims leading/trailing whitespace from a padded array element", () => {
expect(parseIpFromHeaders([" 1.2.3.4 ", "5.6.7.8"])).toBe("1.2.3.4");
});
it("returns an empty string for an empty array instead of throwing", () => {
expect(parseIpFromHeaders([])).toBe("");
});
it("trims tabs and other whitespace, not just spaces", () => {
expect(parseIpFromHeaders("\t1.2.3.4\t, 5.6.7.8")).toBe("1.2.3.4");
});
});
describe("getIP", () => {
describe("with Web Request", () => {
it("returns cf-connecting-ip when present", () => {
const req = buildRequest({ "cf-connecting-ip": "1.1.1.1" });
expect(getIP(req)).toBe("1.1.1.1");
});
it("prefers cf-connecting-ip over other headers", () => {
const req = buildRequest({
"cf-connecting-ip": "1.1.1.1",
"true-client-ip": "2.2.2.2",
"x-forwarded-for": "3.3.3.3",
"x-real-ip": "4.4.4.4",
});
expect(getIP(req)).toBe("1.1.1.1");
});
it("falls back to true-client-ip when cf-connecting-ip is absent", () => {
const req = buildRequest({
"true-client-ip": "2.2.2.2",
"x-forwarded-for": "3.3.3.3",
"x-real-ip": "4.4.4.4",
});
expect(getIP(req)).toBe("2.2.2.2");
});
it("falls back to x-forwarded-for when cf and true-client headers are absent", () => {
const req = buildRequest({
"x-forwarded-for": "3.3.3.3, 10.0.0.1",
"x-real-ip": "4.4.4.4",
});
expect(getIP(req)).toBe("3.3.3.3");
});
it("falls back to x-real-ip as last resort", () => {
const req = buildRequest({ "x-real-ip": "4.4.4.4" });
expect(getIP(req)).toBe("4.4.4.4");
});
it("returns 127.0.0.1 when no IP headers are present", () => {
const req = buildRequest({});
expect(getIP(req)).toBe("127.0.0.1");
});
it("extracts first IP from comma-separated x-forwarded-for", () => {
const req = buildRequest({ "x-forwarded-for": "9.9.9.9, 10.0.0.1, 172.16.0.1" });
expect(getIP(req)).toBe("9.9.9.9");
});
});
describe("banlist bypass regression (#29851)", () => {
const originalBanlist = process.env.IP_BANLIST;
afterEach(() => {
if (originalBanlist === undefined) {
delete process.env.IP_BANLIST;
} else {
process.env.IP_BANLIST = originalBanlist;
}
});
it("still detects a banned IP when x-forwarded-for is padded with whitespace", () => {
process.env.IP_BANLIST = JSON.stringify(["1.2.3.4"]);
const req = buildRequest({ "x-forwarded-for": " 1.2.3.4 , 5.6.7.8" });
expect(isIpInBanlist(req)).toBe(true);
});
});
describe("with NextApiRequest", () => {
it("returns cf-connecting-ip when present", () => {
const req = buildNextApiRequest({ "cf-connecting-ip": "1.1.1.1" });
expect(getIP(req)).toBe("1.1.1.1");
});
it("prefers cf-connecting-ip over other headers", () => {
const req = buildNextApiRequest({
"cf-connecting-ip": "1.1.1.1",
"true-client-ip": "2.2.2.2",
"x-forwarded-for": "3.3.3.3",
"x-real-ip": "4.4.4.4",
});
expect(getIP(req)).toBe("1.1.1.1");
});
it("falls back to true-client-ip when cf-connecting-ip is absent", () => {
const req = buildNextApiRequest({
"true-client-ip": "2.2.2.2",
"x-real-ip": "4.4.4.4",
});
expect(getIP(req)).toBe("2.2.2.2");
});
it("falls back to x-forwarded-for when cf and true-client headers are absent", () => {
const req = buildNextApiRequest({
"x-forwarded-for": "3.3.3.3, 10.0.0.1",
"x-real-ip": "4.4.4.4",
});
expect(getIP(req)).toBe("3.3.3.3");
});
it("falls back to x-real-ip as last resort", () => {
const req = buildNextApiRequest({ "x-real-ip": "4.4.4.4" });
expect(getIP(req)).toBe("4.4.4.4");
});
it("returns 127.0.0.1 when no IP headers are present", () => {
const req = buildNextApiRequest({});
expect(getIP(req)).toBe("127.0.0.1");
});
it("handles array header values (NextApiRequest)", () => {
const req = buildNextApiRequest({ "x-forwarded-for": ["5.5.5.5", "6.6.6.6"] });
expect(getIP(req)).toBe("5.5.5.5");
});
});
});