From 35a3557f50be6e10c0262e7f6719fcb59b3f9e60 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Fri, 21 Aug 2026 19:24:57 -0500 Subject: [PATCH] fix(sdk): stop taking the client IP from a header the client writes The Express and Next.js adapters read the leftmost X-Forwarded-For value and called it the caller's address. That value is written by the client for the first hop, so it was never evidence of anything: one -H 'X-Forwarded-For: 1.2.3.4' bought a fresh rate-limit bucket per forged address, put an address of the caller's choosing on every violation we reported, and made filter({ expression: 'ip.tor' }) an opt-in check. The captcha endpoints in both adapters had the same flaw. Adds a shared resolver in @webdecoy/node that counts trust from the RIGHT of the chain, which is the end written by infrastructure the operator controls. trustProxy is false (believe nothing), a number of hops, 'cloudflare', or CIDRs to walk past. Addresses are normalised first -- ports, brackets and zone ids stripped, IPv4-mapped IPv6 collapsed -- and anything that does not parse falls back to the peer address rather than becoming a key of its own. No node:net, so it runs on Edge and Workers. Behaviour change: Express now defers to req.ip (which honours the app's own `trust proxy` setting), and Next.js reads the chain from the right with a default of one trusted hop, since edge middleware has no socket to fall back on. Fastify already deferred to request.ip and is unchanged. getIP still overrides everything. Closes WebDecoy/app#725 --- CHANGELOG.md | 18 ++ README.md | 25 +- packages/express/src/captcha.ts | 43 ++- packages/express/src/index.ts | 1 + packages/express/src/middleware.ts | 63 +++-- packages/express/src/trusted-proxy.test.ts | 120 ++++++++ packages/fastify/src/plugin.ts | 51 +++- packages/nextjs/src/captcha.ts | 37 ++- packages/nextjs/src/index.ts | 2 +- packages/nextjs/src/middleware.ts | 89 ++++-- packages/webdecoy/src/client-ip.test.ts | 304 +++++++++++++++++++++ packages/webdecoy/src/client-ip.ts | 288 +++++++++++++++++++ packages/webdecoy/src/index.ts | 7 + 13 files changed, 970 insertions(+), 78 deletions(-) create mode 100644 packages/express/src/trusted-proxy.test.ts create mode 100644 packages/webdecoy/src/client-ip.test.ts create mode 100644 packages/webdecoy/src/client-ip.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac8ad12..4c6e579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **The client IP is no longer taken from a header the client writes.** The Express and Next.js adapters read the leftmost `X-Forwarded-For` value and treated it as the caller's address. That value is supplied by the client on the first hop, so a single `-H 'X-Forwarded-For: 1.2.3.4'` bought a fresh rate-limit bucket per forged address, put an address of the caller's choosing on every violation reported to the dashboard, and reduced `filter({ expression: 'ip.tor or ip.vpn' })` to an opt-in check. The captcha endpoints in both adapters had the same flaw. + + Forwarding headers are now believed only as far as you say they should be, counted from the right of the chain — the end written by infrastructure you control. + + - New `trustProxy` option on every adapter and on the captcha endpoints: `false` (believe nothing), a number of trusted hops, `'cloudflare'` (use `CF-Connecting-IP`), or an array of CIDRs to walk past. + - New exports from `@webdecoy/node`: `resolveClientIp()`, `normalizeIp()`, `ipInCidr()`, and the `TrustedProxies` type — so an application building its own `RequestMetadata` derives the same address the middleware does. Edge-safe: no `node:net`. + - Addresses are normalised before use. Ports, brackets and IPv6 zone ids are stripped, IPv4-mapped IPv6 collapses to its IPv4 form so a dual-stack listener keys one client once, and anything that does not parse falls back to the peer address rather than becoming a key of its own. + +### Changed + +- **Behaviour change — read this if you run behind a proxy.** + - **Express** now defers to `req.ip`, which honours the app's own `trust proxy` setting and otherwise resolves to the socket address. An app already configured with `app.set('trust proxy', …)` needs no change. An app behind a proxy that never configured Express will now attribute traffic to the proxy: set `trust proxy`, or pass `trustProxy` to the middleware. + - **Next.js** reads the chain from the right and defaults to `1` trusted hop, which is correct on Vercel and on any single-proxy deployment. Edge middleware has no socket to fall back on, so there is no believe-nothing default available here. Behind a CDN in front of your platform, set `trustProxy: 2`; behind Cloudflare with the origin locked to it, `trustProxy: 'cloudflare'`. + - **Fastify** is unchanged. It already deferred to `request.ip`, which was the safe answer; it gains the `trustProxy` option for parity. + - `getIP` still overrides everything, and existing `getIP` implementations are untouched. + ## [0.11.1] - 2026-08-20 ### Added diff --git a/README.md b/README.md index 44ea3cb..0d1d5e2 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,29 @@ if (!result.allowed) { | [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware | | [@webdecoy/client](https://www.npmjs.com/package/@webdecoy/client) | [![npm](https://img.shields.io/npm/v/@webdecoy/client.svg)](https://www.npmjs.com/package/@webdecoy/client) | Browser-side signal collector | +## Client IP behind a proxy + +Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote. + +```typescript +app.use(webdecoy({ + trustProxy: 1, // one proxy in front of this app +})); +``` + +| `trustProxy` | Meaning | +|---|---| +| `false` | Read no forwarding headers. The socket address is the client. | +| `1`, `2`, … | The number of proxies between the client and this app. | +| `'cloudflare'` | Use `CF-Connecting-IP`. Only safe if the origin is unreachable except through Cloudflare. | +| `['10.0.0.0/8', …]` | CIDRs of proxies you run. The chain is walked right to left and the first address that isn't yours is the client. | + +**Defaults.** Express and Fastify defer to `req.ip` / `request.ip`, which already honour the framework's own trust-proxy setting and otherwise resolve to the socket address — so if you've configured `app.set('trust proxy', …)` there's nothing to do here. Next.js middleware has no socket to fall back on, so it defaults to `1` trusted hop, which is correct on Vercel and on any single-proxy deployment. + +If your app sits behind a proxy and neither of those is configured, set `trustProxy` — otherwise every request is attributed to the proxy and rate limits apply to your whole site at once. + +The same option is accepted by `webdecoyCaptcha()` / `createCaptchaHandler()`, and `getIP` still overrides all of it. To derive the address yourself the same way, `@webdecoy/node` exports `resolveClientIp()`, `normalizeIp()` and `ipInCidr()`. + ## Configuration (platform options) ```typescript @@ -264,7 +287,7 @@ See [examples](./examples) for complete working setups — e.g. [express-basic]( **What if the WebDecoy service is down?** Local rules are unaffected (they never call out). Platform `protect()` fails open by default, so requests continue. -**Behind a CDN or load balancer?** Yes — the middleware handles `X-Forwarded-For` and similar. Configure your proxy's trusted-IP settings correctly. +**Behind a CDN or load balancer?** Yes, but tell the middleware how many proxies are in front of it. See [Client IP behind a proxy](#client-ip-behind-a-proxy). ## Support diff --git a/packages/express/src/captcha.ts b/packages/express/src/captcha.ts index 1699d8a..84871bd 100644 --- a/packages/express/src/captcha.ts +++ b/packages/express/src/captcha.ts @@ -17,17 +17,38 @@ */ import type { Request, Response, NextFunction, RequestHandler } from 'express'; -import { createCaptchaEndpoints, type CaptchaEndpointsOptions } from '@webdecoy/node'; +import { + createCaptchaEndpoints, + resolveClientIp, + normalizeIp, + type CaptchaEndpointsOptions, + type TrustedProxies, +} from '@webdecoy/node'; -function getIP(req: Request): string { - const forwardedFor = req.headers['x-forwarded-for']; - if (forwardedFor) { - const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; - return ips.split(',')[0].trim(); +/** + * The captcha endpoints rate-limit and score by IP, so they need the same + * answer the middleware gets — a forgeable one here would let a solver farm + * mint challenges under an address per request. + */ +function getIP(req: Request, trustProxy: TrustedProxies | undefined): string { + const peer = req.socket?.remoteAddress; + if (trustProxy === undefined) { + return normalizeIp(req.ip) ?? normalizeIp(peer) ?? '127.0.0.1'; } - const realIP = req.headers['x-real-ip']; - if (realIP) return Array.isArray(realIP) ? realIP[0] : realIP; - return req.ip || req.socket.remoteAddress || '127.0.0.1'; + return ( + resolveClientIp({ headers: req.headers, peer, trustProxy }) ?? + normalizeIp(peer) ?? + '127.0.0.1' + ); +} + +export interface ExpressCaptchaOptions extends CaptchaEndpointsOptions { + /** + * How much of the `X-Forwarded-For` chain to believe. Same meaning and same + * default as the middleware's option: unset defers to Express's own + * `trust proxy` setting. + */ + trustProxy?: TrustedProxies; } function normalizeQuery(query: Request['query']): Record { @@ -38,7 +59,7 @@ function normalizeQuery(query: Request['query']): Record => { @@ -48,7 +69,7 @@ export function webdecoyCaptcha(options?: CaptchaEndpointsOptions): RequestHandl query: normalizeQuery(req.query), headers: req.headers as Record, body: req.body, - ip: getIP(req), + ip: getIP(req, options?.trustProxy), }); if (!result) { diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 60d5d5f..c20aee9 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -26,6 +26,7 @@ export type { WebDecoyMiddlewareOptions } from './middleware'; // Self-hosted captcha endpoints (PoW + detection + tokens) export { webdecoyCaptcha } from './captcha'; +export type { ExpressCaptchaOptions } from './captcha'; export type { CaptchaEndpointsOptions } from '@webdecoy/node'; // Re-export core types for convenience diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index e1f36a7..ad6a47e 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -4,12 +4,14 @@ import { Request, Response, NextFunction } from 'express'; import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node'; -import type { EdgeVerdict, SiteHoneytoken } from '@webdecoy/node'; +import type { EdgeVerdict, SiteHoneytoken, TrustedProxies } from '@webdecoy/node'; import { siteHoneytoken, injectHoneytokenLink, isInjectableHtml, tripwire, + resolveClientIp, + normalizeIp, } from '@webdecoy/node'; export interface WebDecoyMiddlewareOptions extends ProtectOptions { @@ -46,8 +48,30 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { honeytoken?: boolean; /** - * Custom function to extract IP address from request - * By default, uses req.ip or x-forwarded-for header + * How much of the `X-Forwarded-For` chain to believe. + * + * Leave this unset and Express decides: `req.ip` already honours the app's own + * `trust proxy` setting, which defaults to the socket address. Set it to + * override that for WebDecoy alone — a number of trusted hops, `'cloudflare'`, + * or CIDRs of your proxies. + * + * THIS CHANGED IN 0.12.0, and it is a behaviour change worth reading. + * + * Before, the middleware read the leftmost `X-Forwarded-For` value and called + * it the client — the one value in that header the client writes itself. A + * single `-H 'X-Forwarded-For: 1.2.3.4'` bought a fresh rate-limit bucket per + * forged address and put an address of the caller's choosing on every + * detection we reported. + * + * If your app is behind a proxy and does not already set `trust proxy`, set + * this, or every request will be attributed to the proxy. + */ + trustProxy?: TrustedProxies; + + /** + * Custom function to extract IP address from request. + * + * Overrides `trustProxy` entirely — you are choosing the address yourself. */ getIP?: (req: Request) => string; @@ -83,25 +107,26 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { } /** - * Default IP extraction function - * Handles various common proxy headers + * The client IP, as far as we are willing to believe it. + * + * With no `trustProxy` we defer to `req.ip`, because Express has already + * answered this question: it applies the app's `trust proxy` setting and falls + * back to the socket address when there isn't one. Deferring means an app that + * has configured its proxies correctly does not have to configure them twice, + * and an app that hasn't gets the peer address instead of a forgeable header. */ -function defaultGetIP(req: Request): string { - // Check X-Forwarded-For header (common with proxies) - const forwardedFor = req.headers['x-forwarded-for']; - if (forwardedFor) { - const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; - return ips.split(',')[0].trim(); - } +function resolveIP(req: Request, trustProxy: TrustedProxies | undefined): string { + const peer = req.socket?.remoteAddress; - // Check X-Real-IP header - const realIP = req.headers['x-real-ip']; - if (realIP) { - return Array.isArray(realIP) ? realIP[0] : realIP; + if (trustProxy === undefined) { + return normalizeIp(req.ip) ?? normalizeIp(peer) ?? '127.0.0.1'; } - // Fall back to req.ip - return req.ip || req.socket.remoteAddress || '127.0.0.1'; + return ( + resolveClientIp({ headers: req.headers, peer, trustProxy }) ?? + normalizeIp(peer) ?? + '127.0.0.1' + ); } /** @@ -173,7 +198,7 @@ export function webdecoy( ): (req: Request, res: Response, next: NextFunction) => Promise { const sdk = new WebDecoy(config); - const getIP = config.getIP || defaultGetIP; + const getIP = config.getIP || ((req: Request) => resolveIP(req, config.trustProxy)); const onBlocked = config.onBlocked || defaultOnBlocked; const mode = config.mode ?? 'monitor'; diff --git a/packages/express/src/trusted-proxy.test.ts b/packages/express/src/trusted-proxy.test.ts new file mode 100644 index 0000000..5cfa7dd --- /dev/null +++ b/packages/express/src/trusted-proxy.test.ts @@ -0,0 +1,120 @@ +import express from 'express'; +import http from 'node:http'; +import { AddressInfo } from 'node:net'; +import { rateLimit } from '@webdecoy/node'; +import { webdecoy } from './middleware'; + +/** + * Which address the middleware keys a request on, through a real Express app. + * + * The resolver has its own unit tests. These cover the thing that was actually + * broken: the middleware used to read the leftmost `X-Forwarded-For` value, so + * a caller could hand itself a fresh rate-limit bucket per request by changing + * one header. A rate limit is the cheapest way to observe the key the + * middleware chose, so that is what these assert on. + */ +function serve(app: express.Express): Promise<{ url: string; close: () => void }> { + return new Promise((resolve) => { + const server = http.createServer(app); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo; + resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() }); + }); + }); +} + +/** An app that allows one request per key, then denies. */ +function appWith(opts: Record = {}) { + const app = express(); + app.use( + webdecoy({ + mode: 'enforce', + rules: [rateLimit({ max: 1, window: 60, action: 'DENY' })], + ...opts, + }), + ); + app.get('/', (_req, res) => res.json({ ok: true })); + return app; +} + +async function statusesFor(url: string, forwardedFor: string[]): Promise { + const out: number[] = []; + for (const value of forwardedFor) { + const res = await fetch(url, { headers: { 'x-forwarded-for': value } }); + out.push(res.status); + } + return out; +} + +describe('client IP resolution in the Express middleware', () => { + it('does not let a forged X-Forwarded-For buy a fresh rate-limit bucket', async () => { + const { url, close } = await serve(appWith()); + try { + // Three requests from one machine, each claiming a different origin. All + // three must key on the real peer, so only the first is allowed. + const statuses = await statusesFor(url, ['1.2.3.4', '5.6.7.8', '9.10.11.12']); + expect(statuses).toEqual([200, 403, 403]); + } finally { + close(); + } + }); + + it('still separates genuinely different clients when hops are declared', async () => { + const { url, close } = await serve(appWith({ trustProxy: 1 })); + try { + // With one declared hop the rightmost entry is the client, and these are + // three different ones, so none of them limits another. + const statuses = await statusesFor(url, ['203.0.113.1', '203.0.113.2', '203.0.113.3']); + expect(statuses).toEqual([200, 200, 200]); + } finally { + close(); + } + }); + + it('with hops declared, padding the chain still cannot dodge the limit', async () => { + const { url, close } = await serve(appWith({ trustProxy: 1 })); + try { + // One client behind the proxy, trying to look like three. The proxy's + // entry is the rightmost one and it does not change. + const statuses = await statusesFor(url, [ + '203.0.113.9', + '1.2.3.4, 203.0.113.9', + '5.6.7.8, 9.10.11.12, 203.0.113.9', + ]); + expect(statuses).toEqual([200, 403, 403]); + } finally { + close(); + } + }); + + it('honours an explicit getIP over trustProxy', async () => { + let seen = 0; + const { url, close } = await serve( + appWith({ + trustProxy: 1, + getIP: () => `198.51.100.${++seen}`, + }), + ); + try { + const statuses = await statusesFor(url, ['1.2.3.4', '1.2.3.4']); + expect(statuses).toEqual([200, 200]); + expect(seen).toBe(2); + } finally { + close(); + } + }); + + it('defers to the app’s own trust proxy setting when trustProxy is unset', async () => { + const app = appWith(); + // Express resolves req.ip from the chain once the app opts in, and the + // middleware follows it rather than making the operator say it twice. + app.set('trust proxy', 1); + const { url, close } = await serve(app); + try { + const statuses = await statusesFor(url, ['203.0.113.1', '203.0.113.2']); + expect(statuses).toEqual([200, 200]); + } finally { + close(); + } + }); +}); diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index 83f7094..be09a03 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -13,8 +13,10 @@ import { injectHoneytokenLink, isInjectableHtml, tripwire, + resolveClientIp, + normalizeIp, } from '@webdecoy/node'; -import type { EdgeVerdict, SiteHoneytoken } from '@webdecoy/node'; +import type { EdgeVerdict, SiteHoneytoken, TrustedProxies } from '@webdecoy/node'; export interface WebDecoyPluginOptions extends ProtectOptions { /** @@ -49,8 +51,23 @@ export interface WebDecoyPluginOptions extends ProtectOptions { honeytoken?: boolean; /** - * Custom function to extract IP address from request - * By default, uses request.ip or x-forwarded-for header + * How much of the `X-Forwarded-For` chain to believe. + * + * Leave this unset and Fastify decides: `request.ip` already honours the + * server's own `trustProxy` option, which defaults to the socket address. Set + * it to override that for WebDecoy alone — a number of trusted hops, + * `'cloudflare'`, or CIDRs of your proxies. + * + * Fastify's default was already the safe one, so unlike the Express and + * Next.js adapters nothing changes here in 0.12.0. The option exists so all + * three adapters answer the question the same way. + */ + trustProxy?: TrustedProxies; + + /** + * Custom function to extract IP address from request. + * + * Overrides `trustProxy` entirely — you are choosing the address yourself. */ getIP?: (req: FastifyRequest) => string; @@ -73,11 +90,29 @@ export interface WebDecoyPluginOptions extends ProtectOptions { } /** - * Default IP extraction function for Fastify + * The client IP, as far as we are willing to believe it. + * + * With no `trustProxy` we defer to `request.ip`, which Fastify derives from its + * own `trustProxy` server option and which falls back to the socket address. + * Deferring means an app that has configured its proxies correctly does not have + * to configure them twice. */ -function defaultGetIP(req: FastifyRequest): string { - // Fastify provides req.ip which handles x-forwarded-for - return req.ip || '127.0.0.1'; +function resolveIP(req: FastifyRequest, trustProxy: TrustedProxies | undefined): string { + const peer = req.socket?.remoteAddress; + + if (trustProxy === undefined) { + return normalizeIp(req.ip) ?? normalizeIp(peer) ?? '127.0.0.1'; + } + + return ( + resolveClientIp({ + headers: req.headers as Record, + peer, + trustProxy, + }) ?? + normalizeIp(peer) ?? + '127.0.0.1' + ); } /** @@ -160,7 +195,7 @@ async function webdecoyPluginImpl( ): Promise { const sdk = new WebDecoy(options); - const getIP = options.getIP || defaultGetIP; + const getIP = options.getIP || ((req: FastifyRequest) => resolveIP(req, options.trustProxy)); const onBlocked = options.onBlocked || defaultOnBlocked; const mode = options.mode ?? 'monitor'; const onError = options.onError || defaultOnError; diff --git a/packages/nextjs/src/captcha.ts b/packages/nextjs/src/captcha.ts index 753303a..07f4bfd 100644 --- a/packages/nextjs/src/captcha.ts +++ b/packages/nextjs/src/captcha.ts @@ -12,18 +12,39 @@ * ``` */ -import { createCaptchaEndpoints, type CaptchaEndpointsOptions } from '@webdecoy/node'; +import { + createCaptchaEndpoints, + resolveClientIp, + normalizeIp, + type CaptchaEndpointsOptions, + type TrustedProxies, +} from '@webdecoy/node'; -function getIP(headers: Headers): string { - const forwarded = headers.get('x-forwarded-for'); - if (forwarded) return forwarded.split(',')[0].trim(); +/** + * The captcha endpoints rate-limit and score by IP, so they need the same + * answer the middleware gets — a forgeable one here would let a solver farm + * mint challenges under an address per request. Defaults to one trusted hop for + * the same reason the middleware does: a route handler has no socket to fall + * back on. + */ +function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string { + const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }); + if (fromChain) return fromChain; return ( - headers.get('x-real-ip') || - headers.get('x-vercel-forwarded-for') || + normalizeIp(headers.get('x-real-ip')) ?? + normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ?? '127.0.0.1' ); } +export interface NextCaptchaOptions extends CaptchaEndpointsOptions { + /** + * How many proxies sit between the client and this handler, or which ones. + * Defaults to `1`. Same meaning as the middleware's option. + */ + trustProxy?: TrustedProxies; +} + function headersToRecord(headers: Headers): Record { const out: Record = {}; headers.forEach((value, key) => { @@ -37,7 +58,7 @@ export interface NextCaptchaHandlers { POST: (req: Request) => Promise; } -export function createCaptchaHandler(options?: CaptchaEndpointsOptions): NextCaptchaHandlers { +export function createCaptchaHandler(options?: NextCaptchaOptions): NextCaptchaHandlers { const endpoints = createCaptchaEndpoints(options); async function handler(req: Request): Promise { @@ -63,7 +84,7 @@ export function createCaptchaHandler(options?: CaptchaEndpointsOptions): NextCap query, headers, body, - ip: getIP(req.headers), + ip: getIP(req.headers, options?.trustProxy), }); if (!result) { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index e55233e..d24b689 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -31,7 +31,7 @@ export type { SiteHoneytoken } from './honeytoken'; // Self-hosted captcha route handlers (PoW + detection + tokens) export { createCaptchaHandler } from './captcha'; -export type { NextCaptchaHandlers } from './captcha'; +export type { NextCaptchaHandlers, NextCaptchaOptions } from './captcha'; export type { CaptchaEndpointsOptions } from '@webdecoy/node'; // Re-export core types for convenience diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index 68a3ecf..869a465 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -3,7 +3,15 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node'; +import { + WebDecoy, + WebDecoyConfig, + RequestMetadata, + ProtectOptions, + resolveClientIp, + normalizeIp, +} from '@webdecoy/node'; +import type { TrustedProxies } from '@webdecoy/node'; export interface WebDecoyMiddlewareOptions extends ProtectOptions { /** @@ -20,8 +28,29 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { mode?: 'monitor' | 'enforce'; /** - * Custom function to extract IP address from request - * By default, uses x-forwarded-for or x-real-ip headers + * How many proxies sit between the client and this middleware, or which ones. + * Defaults to `1` — the hosting platform in front of you. + * + * THIS CHANGED IN 0.12.0, and it is a behaviour change worth reading. + * + * Before, the middleware read the LEFTMOST `X-Forwarded-For` value. That is + * the one value in the header the client writes itself, so a single + * `-H 'X-Forwarded-For: 1.2.3.4'` bought a fresh rate-limit bucket per forged + * address and put an address of the caller's choosing on every detection we + * reported. It now reads from the right, past the hops you say you have. + * + * Edge middleware has no socket to fall back on, so unlike the Express and + * Fastify adapters there is no safe "believe nothing" default here — `1` is + * correct on Vercel and on any single-proxy deployment. Behind a CDN in front + * of your platform, set `2`. Behind Cloudflare with the origin locked to it, + * `'cloudflare'` is stronger than counting. + */ + trustProxy?: TrustedProxies; + + /** + * Custom function to extract IP address from request. + * + * Overrides `trustProxy` entirely — you are choosing the address yourself. */ getIP?: (req: NextRequest) => string; @@ -50,28 +79,25 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { } /** - * Default IP extraction function for Next.js + * The client IP, as far as we are willing to believe it. + * + * `x-real-ip` and `x-vercel-forwarded-for` are consulted only after the + * forwarding chain comes up empty. Both are written by a proxy in the normal + * case and by anyone at all otherwise, so they are a fallback for a missing + * `X-Forwarded-For`, never an override of one. */ -function defaultGetIP(req: NextRequest): string { - // Check X-Forwarded-For header (common with Vercel and proxies) - const forwardedFor = req.headers.get('x-forwarded-for'); - if (forwardedFor) { - return forwardedFor.split(',')[0].trim(); - } - - // Check X-Real-IP header - const realIP = req.headers.get('x-real-ip'); - if (realIP) { - return realIP; - } - - // Vercel provides this - const vercelIP = req.headers.get('x-vercel-forwarded-for'); - if (vercelIP) { - return vercelIP.split(',')[0].trim(); - } +function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string { + const fromChain = resolveClientIp({ + headers: req.headers, + trustProxy: trustProxy ?? 1, + }); + if (fromChain) return fromChain; - return '127.0.0.1'; + return ( + normalizeIp(req.headers.get('x-real-ip')) ?? + normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ?? + '127.0.0.1' + ); } /** @@ -137,7 +163,7 @@ export function withWebDecoy( ): (req: NextRequest) => Promise { const sdk = new WebDecoy(config); - const getIP = config.getIP || defaultGetIP; + const getIP = config.getIP || ((req: NextRequest) => resolveIP(req, config.trustProxy)); const onBlocked = config.onBlocked || defaultOnBlocked; const mode = config.mode ?? 'monitor'; const onError = config.onError || defaultOnError; @@ -297,16 +323,19 @@ export function withBotProtection any>( const [req, res] = args; try { - // Extract IP from various sources - const forwardedFor = req.headers['x-forwarded-for']; - const ip = forwardedFor - ? (Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor).split(',')[0].trim() - : req.headers['x-real-ip'] || req.socket?.remoteAddress || '127.0.0.1'; + // A Pages API route runs on Node, so there is a socket here and the safe + // default the edge middleware cannot have applies: believe no forwarding + // header unless the caller says how many proxies wrote it. + const peer = req.socket?.remoteAddress; + const ip = + resolveClientIp({ headers: req.headers, peer, trustProxy: config.trustProxy }) ?? + normalizeIp(peer) ?? + '127.0.0.1'; const metadata: RequestMetadata = { method: req.method || 'GET', path: req.url || '/', - ip: typeof ip === 'string' ? ip : '127.0.0.1', + ip, user_agent: req.headers['user-agent'], headers: req.headers as Record, timestamp: Date.now(), diff --git a/packages/webdecoy/src/client-ip.test.ts b/packages/webdecoy/src/client-ip.test.ts new file mode 100644 index 0000000..e79b69e --- /dev/null +++ b/packages/webdecoy/src/client-ip.test.ts @@ -0,0 +1,304 @@ +import { resolveClientIp, normalizeIp, ipInCidr } from './client-ip'; + +/** A Node-style header bag. */ +const h = (headers: Record) => headers; + +/** A WHATWG Headers, as the edge adapters pass. */ +const web = (headers: Record) => new Headers(headers); + +describe('normalizeIp', () => { + it.each([ + ['203.0.113.9', '203.0.113.9'], + [' 203.0.113.9 ', '203.0.113.9'], + ['203.0.113.9:44321', '203.0.113.9'], + ['[2001:db8::1]:8080', '2001:db8::1'], + ['[2001:db8::1]', '2001:db8::1'], + ['2001:DB8::1', '2001:db8::1'], + ['fe80::1%eth0', 'fe80::1'], + ['::1', '::1'], + ['::', '::'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeIp(input)).toBe(expected); + }); + + it('collapses IPv4-mapped IPv6 so a dual-stack listener keys the same client once', () => { + expect(normalizeIp('::ffff:203.0.113.9')).toBe('203.0.113.9'); + expect(normalizeIp('[::ffff:203.0.113.9]:443')).toBe('203.0.113.9'); + }); + + it.each([ + ['', 'empty'], + [' ', 'whitespace'], + ['not-an-ip', 'garbage'], + ['203.0.113.999', 'octet out of range'], + ['203.0.113', 'too few octets'], + ['010.0.0.1', 'leading zero — decimal and octal readers disagree'], + ['2001:db8::1::2', 'two :: runs'], + ['2001:db8:::1', 'malformed run'], + ['12345::1', 'group too long'], + ['2001:db8:0:0:0:0:0:0:1', 'nine groups'], + ['