diff --git a/CHANGELOG.md b/CHANGELOG.md index 9622ad5..6d6dc7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, `, 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 diff --git a/packages/express/README.md b/packages/express/README.md index 060d0f2..b447fdc 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -106,6 +106,8 @@ app.use( ); ``` +On Railway, use `trustProxy: 'railway'` (or `app.set('trust proxy', 2)`). Railway's edge writes `X-Forwarded-For: , `, so trusting one hop records Railway's edge as every visitor. + ## Custom Block Handler ```typescript diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 7704845..abd8e51 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -59,7 +59,7 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { * 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. * diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index b1942e0..556e926 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -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 diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index c9c91e5..66ed0a9 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -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; diff --git a/packages/webdecoy/src/client-ip.test.ts b/packages/webdecoy/src/client-ip.test.ts index aa982e3..90fd69e 100644 --- a/packages/webdecoy/src/client-ip.test.ts +++ b/packages/webdecoy/src/client-ip.test.ts @@ -216,6 +216,44 @@ describe('resolveClientIp', () => { }); }); + describe('railway', () => { + // The shape Railway's edge produces: whatever the client sent is replaced by + // `, `, 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']; diff --git a/packages/webdecoy/src/client-ip.ts b/packages/webdecoy/src/client-ip.ts index dba985c..062951e 100644 --- a/packages/webdecoy/src/client-ip.ts +++ b/packages/webdecoy/src/client-ip.ts @@ -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 + * `, `, 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 = @@ -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 + // `, `, 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