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

58 lines
2.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import process from "node:process";
import type { NextApiRequest } from "next";
import z from "zod";
import logger from "./logger";
export function parseIpFromHeaders(value: string | string[]) {
const rawIp = Array.isArray(value) ? value[0] : value.split(",")[0];
return rawIp?.trim() ?? "";
}
/**
* Tries to extract IP address from a request.
*
* Header priority (CF → Vercel setup):
* 1. cf-connecting-ip – set by Cloudflare with the real client IP
* 2. true-client-ip – set by Cloudflare (Enterprise / Managed Transforms)
* 3. x-forwarded-for – first IP is the real client; survives the CF → Vercel hop
* 4. x-real-ip – set by Vercel to the *connecting* IP (CF edge IP when
* behind Cloudflare, so least reliable)
*
* @see https://github.com/vercel/examples/blob/main/edge-functions/ip-blocking/lib/get-ip.ts
**/
export default function getIP(request: Request | NextApiRequest) {
const headers: readonly string[] = ["cf-connecting-ip", "true-client-ip", "x-forwarded-for", "x-real-ip"];
for (const header of headers) {
const value = request instanceof Request ? request.headers.get(header) : request.headers[header];
if (value) {
return parseIpFromHeaders(value);
}
}
return "127.0.0.1";
}
const banlistSchema = z.array(z.string());
export function isIpInBanlist(request: Request | NextApiRequest) {
const IP = getIP(request);
const rawBanListJson = process.env.IP_BANLIST || "[]";
const banList = banlistSchema.parse(JSON.parse(rawBanListJson));
if (banList.includes(IP)) {
logger.warn(`Found banned IP: ${IP} in IP_BANLIST`);
return true;
}
return false;
}
export function isIpInBanListString(identifer: string) {
const rawBanListJson = process.env.IP_BANLIST || "[]";
const banList = banlistSchema.parse(JSON.parse(rawBanListJson));
if (banList.includes(identifer)) {
logger.warn(`Found banned IP: ${identifer} in IP_BANLIST`);
return true;
}
return false;
}