* 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
58 lines
2.0 KiB
TypeScript
58 lines
2.0 KiB
TypeScript
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;
|
||
} |