From c9abc556baf2ac8ad895e96915563ddfad73de5e Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Tue, 24 Feb 2026 14:47:14 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20improve=20getIP=20header=20resolution=20?= =?UTF-8?q?for=20CF=20=E2=86=92=20Vercel=20setup=20(#28152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: unknown <> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../web/app/api/auth/forgot-password/route.ts | 6 +- apps/web/app/api/ip/route.ts | 2 +- packages/lib/getIP.test.ts | 124 ++++++++++++++++++ packages/lib/getIP.ts | 26 ++-- 4 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 packages/lib/getIP.test.ts diff --git a/apps/web/app/api/auth/forgot-password/route.ts b/apps/web/app/api/auth/forgot-password/route.ts index 3f45de0fad..b0e3fb6730 100644 --- a/apps/web/app/api/auth/forgot-password/route.ts +++ b/apps/web/app/api/auth/forgot-password/route.ts @@ -17,11 +17,7 @@ async function handler(req: NextRequest) { return NextResponse.json({ message: "email is required" }, { status: 400 }); } - let ip = getIP(req) ?? email.data; - const forwardedFor = req.headers.get("x-forwarded-for") as string; - if (!ip && forwardedFor) { - ip = forwardedFor?.split(",").at(0) ?? email.data; - } + const ip = getIP(req) ?? email.data; await checkRateLimitAndThrowError({ rateLimitingType: "core", diff --git a/apps/web/app/api/ip/route.ts b/apps/web/app/api/ip/route.ts index c834dd087a..95f7fba0c8 100644 --- a/apps/web/app/api/ip/route.ts +++ b/apps/web/app/api/ip/route.ts @@ -1,5 +1,5 @@ -import { NextResponse } from "next/server"; import getIP from "@calcom/lib/getIP"; +import { NextResponse } from "next/server"; export async function GET(req: Request) { const requestorIp = getIP(req); diff --git a/packages/lib/getIP.test.ts b/packages/lib/getIP.test.ts new file mode 100644 index 0000000000..b5c3c3c523 --- /dev/null +++ b/packages/lib/getIP.test.ts @@ -0,0 +1,124 @@ +import type { NextApiRequest } from "next"; +import { describe, expect, it } from "vitest"; +import getIP, { parseIpFromHeaders } from "./getIP"; + +function buildRequest(headers: Record): Request { + return new Request("https://example.com", { headers }); +} + +function buildNextApiRequest(headers: Record): 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"); + }); +}); + +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("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"); + }); + }); +}); diff --git a/packages/lib/getIP.ts b/packages/lib/getIP.ts index cf1c03ace1..1962b417ce 100644 --- a/packages/lib/getIP.ts +++ b/packages/lib/getIP.ts @@ -1,6 +1,6 @@ +import process from "node:process"; import type { NextApiRequest } from "next"; import z from "zod"; - import logger from "./logger"; export function parseIpFromHeaders(value: string | string[]) { @@ -8,20 +8,28 @@ export function parseIpFromHeaders(value: string | string[]) { } /** - * Tries to extract IP address from a request + * 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) { - let xff = - request instanceof Request - ? request.headers.get("cf-connecting-ip") - : request.headers["cf-connecting-ip"]; + const headers: readonly string[] = ["cf-connecting-ip", "true-client-ip", "x-forwarded-for", "x-real-ip"]; - if (!xff) { - xff = request instanceof Request ? request.headers.get("x-real-ip") : request.headers["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 xff ? parseIpFromHeaders(xff) : "127.0.0.1"; + return "127.0.0.1"; } const banlistSchema = z.array(z.string());