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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`trustProxy: 'railway'`.** Railway's edge replaces any `X-Forwarded-For` the client sent with exactly `<client>, <edge>`, so the visitor is two entries from the right. A depth of `1`, and the Next.js and Hono default, names Railway's edge for every visitor instead. `'railway'` is the same answer as a depth of `2`, under a name you do not have to work out. Works in every adapter.

### Changed

- **Fastify: pass the hop count to the plugin, not to Fastify.** Since Fastify 5.12 a numeric server `trustProxy` trusts no hop at all, so `request.ip` stays the socket address. The plugin's own `trustProxy` is unaffected; its documentation now says so.

## [0.17.0] - 2026-09-24

### Added
Expand Down
2 changes: 2 additions & 0 deletions packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ app.use(
);
```

On Railway, use `trustProxy: 'railway'` (or `app.set('trust proxy', 2)`). Railway's edge writes `X-Forwarded-For: <client>, <edge>`, so trusting one hop records Railway's edge as every visitor.

## Custom Block Handler

```typescript
Expand Down
2 changes: 1 addition & 1 deletion packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
* 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.
* `'railway'`, or CIDRs of your proxies.
*
* THIS CHANGED IN 0.12.0, and it is a behaviour change worth reading.
*
Expand Down Expand Up @@ -284,13 +284,13 @@
return intercepting;
};

(res as any).write = function (chunk: any, ...rest: any[]): boolean {

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 287 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
if (!shouldIntercept()) return originalWrite(chunk, ...rest);
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return true;
};

(res as any).end = function (chunk: any, ...rest: any[]): any {

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 293 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
try {
if (!shouldIntercept()) return originalEnd(chunk, ...rest);
if (chunk && typeof chunk !== 'function') {
Expand Down Expand Up @@ -325,8 +325,8 @@
// name in every adapter, which `webdecoy` cannot.
req.webdecoy = result.detection;
req.webdecoyDecision = result;
(req as any).webdecoyEdge = result.edge;

Check warning on line 328 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 328 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
(req as any).webdecoyWouldBlock = !result.allowed;

Check warning on line 329 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 329 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
return next();
}

Expand Down Expand Up @@ -354,7 +354,7 @@
// req.webdecoyEdge.isScript instead of string-matching x-wd-class, and
// `present: false` tells it the edge was never in front of this request —
// which is no information, not a clean bill of health.
(req as any).webdecoyEdge = result.edge;

Check warning on line 357 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 357 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
return next();
} else {
// Block the request
Expand Down
6 changes: 5 additions & 1 deletion packages/fastify/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ export interface WebDecoyPluginOptions extends ProtectOptions {
* 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.
* `'cloudflare'`, `'railway'`, or CIDRs of your proxies.
*
* Prefer this over Fastify's own option for a hop count: since Fastify 5.12 a
* numeric server `trustProxy` trusts no hop at all (a count cannot tell a
* proxy from a direct client), so `request.ip` stays the socket address.
*
* 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
Expand Down
4 changes: 3 additions & 1 deletion packages/nextjs/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions {
* 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.
* `'cloudflare'` is stronger than counting. On Railway, set `'railway'`: its
* edge writes two entries, so the default of `1` names the edge, not the
* visitor.
*/
trustProxy?: TrustedProxies;

Expand Down
38 changes: 38 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,44 @@ describe('resolveClientIp', () => {
});
});

describe('railway', () => {
// The shape Railway's edge produces: whatever the client sent is replaced by
// `<client>, <edge>`, and the socket peer is an internal address.
const railway = h({ 'x-forwarded-for': '203.0.113.9, 198.51.100.7' });

it('reads the client two entries from the right', () => {
expect(
resolveClientIp({ headers: railway, peer: '100.64.0.2', trustProxy: 'railway' }),
).toBe('203.0.113.9');
});

it('works without a peer, as in edge middleware', () => {
expect(resolveClientIp({ headers: railway, trustProxy: 'railway' })).toBe('203.0.113.9');
});

it('answers exactly what a depth of 2 answers', () => {
const chains = [
'203.0.113.9, 198.51.100.7',
'1.2.3.4, 203.0.113.9, 198.51.100.7',
'203.0.113.9',
'',
];
for (const xff of chains) {
const headers = h(xff ? { 'x-forwarded-for': xff } : {});
expect(resolveClientIp({ headers, peer: '100.64.0.2', trustProxy: 'railway' })).toBe(
resolveClientIp({ headers, peer: '100.64.0.2', trustProxy: 2 }),
);
}
});

it('does not name the edge, which a depth of 1 would', () => {
expect(resolveClientIp({ headers: railway, trustProxy: 1 })).toBe('198.51.100.7');
expect(resolveClientIp({ headers: railway, trustProxy: 'railway' })).not.toBe(
'198.51.100.7',
);
});
});

describe('a CIDR list', () => {
const trustProxy = ['10.0.0.0/8', '172.16.0.0/12'];

Expand Down
13 changes: 12 additions & 1 deletion packages/webdecoy/src/client-ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,16 @@
* - `'cloudflare'` — use `CF-Connecting-IP`. Only meaningful if the origin is
* unreachable except through Cloudflare, since the header is otherwise just
* another thing a client can send.
* - `'railway'` — the app runs on Railway with nothing else in front. Railway's
* edge replaces any `X-Forwarded-For` the client sent with exactly
* `<client>, <edge>`, so the client is two entries from the right. The
* common guess of `1` names Railway's edge for every visitor. With a CDN in
* front of Railway, configure for the CDN instead (`'cloudflare'`).
* - `string[]` — CIDRs (or bare addresses) of the proxies you run. The chain is
* walked right to left and the first address that isn't one of yours is the
* client. Use this when the depth varies.
*/
export type TrustedProxies = false | number | 'cloudflare' | string[];
export type TrustedProxies = false | number | 'cloudflare' | 'railway' | string[];

/** Headers as either a Node-style bag or a WHATWG `Headers`. */
export type HeaderSource =
Expand Down Expand Up @@ -260,6 +265,12 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
return normalizeIp(readHeader(headers, 'cf-connecting-ip')) ?? peer;
}

// A name for a depth, not a new rule: Railway's edge rewrites the chain to
// `<client>, <edge>`, which is two trusted hops under the counting model.
if (trustProxy === 'railway') {
return resolveClientIp({ headers, peer: options.peer, trustProxy: 2 });
}

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
Expand Down
Loading