Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?** Yesthe 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

Expand Down
43 changes: 32 additions & 11 deletions packages/express/src/captcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> {
Expand All @@ -38,7 +59,7 @@ function normalizeQuery(query: Request['query']): Record<string, string | undefi
return out;
}

export function webdecoyCaptcha(options?: CaptchaEndpointsOptions): RequestHandler {
export function webdecoyCaptcha(options?: ExpressCaptchaOptions): RequestHandler {
const endpoints = createCaptchaEndpoints(options);

return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
Expand All @@ -48,7 +69,7 @@ export function webdecoyCaptcha(options?: CaptchaEndpointsOptions): RequestHandl
query: normalizeQuery(req.query),
headers: req.headers as Record<string, string>,
body: req.body,
ip: getIP(req),
ip: getIP(req, options?.trustProxy),
});

if (!result) {
Expand Down
1 change: 1 addition & 0 deletions packages/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 44 additions & 19 deletions packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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'
);
}

/**
Expand Down Expand Up @@ -173,7 +198,7 @@ export function webdecoy(
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
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';

Expand Down
120 changes: 120 additions & 0 deletions packages/express/src/trusted-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<number[]> {
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();
}
});
});
Loading