Files
next-ai-draw-io/tests/unit/client-ip.test.ts
T
dayuan.jiang 74ca64f5e8 feat(api): count the quota by the CDN's client IP and refuse direct calls
Two optional settings for deployments behind a CDN such as Cloudflare.

CLIENT_IP_HEADER names the header that holds the visitor's real IP
(cf-connecting-ip on Cloudflare). The per-IP daily quota used the first
X-Forwarded-For entry, which visitors can set to anything, so a made-up
IP on every request got a fresh quota.

ORIGIN_SECRET makes proxy.ts refuse /api requests whose X-Origin-Secret
header does not match. The CDN adds the header, so a call that skips the
CDN, and could fake the IP header, gets 403. Pages are not checked, which
keeps health checks on / working.

Both are unset by default, and nothing changes then.
2026-10-06 10:12:15 +09:00

64 lines
2.0 KiB
TypeScript

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()
})
})