diff --git a/env.example b/env.example index c812e00f..ffdc0f0b 100644 --- a/env.example +++ b/env.example @@ -163,6 +163,16 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # Set to "false" to block private IPs, localhost, and internal hostnames # ALLOW_PRIVATE_URLS=false +# Behind a CDN such as Cloudflare (Optional) +# The daily quota is counted per IP. By default the IP is the first +# X-Forwarded-For entry, which visitors can set to anything. Name the header +# your CDN fills with the visitor's real IP instead: +# CLIENT_IP_HEADER=cf-connecting-ip +# Then have the CDN add an X-Origin-Secret header with this value to every +# request. API calls without it get 403, so nobody can skip the CDN and fake +# the IP header above. +# ORIGIN_SECRET=a-long-random-string + # Self-hosted deployment (Optional) # Self-hosted users may implement custom quota-management solutions, # which triggers the client UI to display messages suggesting self-hosting or sponsorship. diff --git a/lib/user-id.ts b/lib/user-id.ts index 3d7d7181..2a347afa 100644 --- a/lib/user-id.ts +++ b/lib/user-id.ts @@ -2,10 +2,19 @@ * Generate a userId from request for tracking purposes. * Uses base64url encoding of IP for URL-safe identifier. * Note: base64 is reversible - this is NOT privacy protection. + * + * The first X-Forwarded-For entry is whatever the visitor sent, so behind a + * CDN set CLIENT_IP_HEADER to the header it fills with the real IP (e.g. + * cf-connecting-ip), and ORIGIN_SECRET so requests that skip the CDN are + * refused (see proxy.ts). */ export function getUserIdFromRequest(req: Request): string { - const forwardedFor = req.headers.get("x-forwarded-for") - const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous" + const ipHeader = process.env.CLIENT_IP_HEADER + const rawIp = + (ipHeader + ? req.headers.get(ipHeader)?.trim() + : req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()) || + "anonymous" return rawIp === "anonymous" ? rawIp : `user-${Buffer.from(rawIp).toString("base64url")}` diff --git a/proxy.ts b/proxy.ts index fdf43a43..7616d2e5 100644 --- a/proxy.ts +++ b/proxy.ts @@ -27,9 +27,19 @@ function getLocale(request: NextRequest): string | undefined { export function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname - // Skip API routes, static files, and Next.js internals + if (pathname.startsWith("/api/")) { + // With ORIGIN_SECRET set, API calls must come through the CDN that + // adds this header. A call straight to the origin could fake the + // CLIENT_IP_HEADER and get a fresh quota for every made-up IP. + const secret = process.env.ORIGIN_SECRET + if (secret && request.headers.get("x-origin-secret") !== secret) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + return + } + + // Skip static files and Next.js internals if ( - pathname.startsWith("/api/") || pathname.startsWith("/_next/") || pathname.startsWith("/drawio") || pathname.includes("/favicon") || @@ -58,6 +68,9 @@ export function proxy(request: NextRequest) { } export const config = { - // Matcher ignoring `/_next/` and `/api/` - matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], + // API routes (for ORIGIN_SECRET), and pages without `/_next/` assets + matcher: [ + "/api/:path*", + "/((?!api|_next/static|_next/image|favicon.ico).*)", + ], } diff --git a/tests/unit/client-ip.test.ts b/tests/unit/client-ip.test.ts new file mode 100644 index 00000000..caec89b3 --- /dev/null +++ b/tests/unit/client-ip.test.ts @@ -0,0 +1,63 @@ +import { NextRequest } from "next/server" +import { afterEach, describe, expect, it } from "vitest" +import { getUserIdFromRequest } from "@/lib/user-id" +import { proxy } from "@/proxy" + +const idFor = (ip: string) => `user-${Buffer.from(ip).toString("base64url")}` + +afterEach(() => { + delete process.env.CLIENT_IP_HEADER + delete process.env.ORIGIN_SECRET +}) + +describe("getUserIdFromRequest", () => { + const req = new Request("http://localhost/api/chat", { + headers: { + "x-forwarded-for": "203.0.113.9, 198.51.100.7", + "cf-connecting-ip": "198.51.100.7", + }, + }) + + it("uses the first X-Forwarded-For entry by default", () => { + expect(getUserIdFromRequest(req)).toBe(idFor("203.0.113.9")) + }) + + it("uses CLIENT_IP_HEADER when set", () => { + process.env.CLIENT_IP_HEADER = "cf-connecting-ip" + expect(getUserIdFromRequest(req)).toBe(idFor("198.51.100.7")) + }) + + it("is anonymous when the configured header is missing", () => { + process.env.CLIENT_IP_HEADER = "cf-connecting-ip" + expect( + getUserIdFromRequest(new Request("http://localhost/api/chat")), + ).toBe("anonymous") + }) +}) + +describe("ORIGIN_SECRET", () => { + const call = (path: string, secret?: string) => + proxy( + new NextRequest(`http://localhost${path}`, { + headers: secret ? { "x-origin-secret": secret } : {}, + }), + ) + + it("lets every API call through when unset", () => { + expect(call("/api/chat")).toBeUndefined() + }) + + it("refuses API calls without the right header", async () => { + process.env.ORIGIN_SECRET = "s3cret" + for (const res of [call("/api/chat"), call("/api/chat", "wrong")]) { + expect(res?.status).toBe(403) + } + expect(call("/api/chat", "s3cret")).toBeUndefined() + }) + + it("leaves pages alone, such as the health check on /", () => { + process.env.ORIGIN_SECRET = "s3cret" + expect(call("/")?.status).toBe(307) + expect(call("/en")).toBeUndefined() + }) +})