From 445be28acc9b3f392fcf2103edcc338f20872dc1 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 26 Sep 2026 22:27:33 -0500 Subject: [PATCH 1/2] feat: trustProxy: 'railway' Railway's edge replaces any client-sent X-Forwarded-For with exactly ', ', so the visitor is two entries from the right. A depth of 1 (and the Next.js and Hono default) names the edge for every visitor. 'railway' resolves exactly as a depth of 2, in the one shared resolver, so every adapter gets it. Also documents that Fastify 5.12+ ignores a numeric server trustProxy (it fails closed), so the hop count belongs on the plugin. Co-Authored-By: Claude --- CHANGELOG.md | 8 +++++ packages/express/README.md | 2 ++ packages/express/src/middleware.ts | 10 +++--- packages/fastify/src/plugin.ts | 12 ++++--- packages/nextjs/src/middleware.ts | 6 ++-- packages/webdecoy/src/client-ip.test.ts | 43 ++++++++++++++++++++++--- packages/webdecoy/src/client-ip.ts | 15 +++++++-- 7 files changed, 78 insertions(+), 18 deletions(-) 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..66f758f 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. * @@ -101,7 +101,7 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { res: Response, detection: SDKDetectionResponse, next: NextFunction, - decision: ProtectResult, + decision: ProtectResult ) => void; /** @@ -139,9 +139,7 @@ function resolveIP(req: Request, trustProxy: TrustedProxies | undefined): string } return ( - resolveClientIp({ headers: req.headers, peer, trustProxy }) ?? - normalizeIp(peer) ?? - '127.0.0.1' + resolveClientIp({ headers: req.headers, peer, trustProxy }) ?? normalizeIp(peer) ?? '127.0.0.1' ); } @@ -152,7 +150,7 @@ function defaultOnBlocked( req: Request, res: Response, detection: SDKDetectionResponse, - _next: NextFunction, + _next: NextFunction ): void { res.status(403).json({ error: 'Forbidden', diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index b1942e0..1a21097 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 @@ -90,7 +94,7 @@ export interface WebDecoyPluginOptions extends ProtectOptions { req: FastifyRequest, reply: FastifyReply, detection: SDKDetectionResponse, - decision: ProtectResult, + decision: ProtectResult ) => void; /** @@ -137,7 +141,7 @@ function resolveIP(req: FastifyRequest, trustProxy: TrustedProxies | undefined): function defaultOnBlocked( req: FastifyRequest, reply: FastifyReply, - detection: SDKDetectionResponse, + detection: SDKDetectionResponse ): void { reply.status(403).send({ error: 'Forbidden', @@ -350,7 +354,7 @@ async function webdecoyPluginImpl( '[WebDecoy] HTML is being streamed, so the honeytoken link was not injected. ' + 'Render to a string, or embed the link yourself: ' + `', + 'style="position:absolute;left:-9999px">.' ); } return payload; diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index c9c91e5..3c765ac 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; @@ -71,7 +73,7 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { onBlocked?: ( req: NextRequest, detection: SDKDetectionResponse, - decision: ProtectResult, + decision: ProtectResult ) => NextResponse; /** diff --git a/packages/webdecoy/src/client-ip.test.ts b/packages/webdecoy/src/client-ip.test.ts index aa982e3..6c17e9d 100644 --- a/packages/webdecoy/src/client-ip.test.ts +++ b/packages/webdecoy/src/client-ip.test.ts @@ -203,19 +203,54 @@ describe('resolveClientIp', () => { }); it('falls back to the peer when the header is missing or junk', () => { - expect( - resolveClientIp({ headers: h({}), peer: '10.0.0.1', trustProxy: 'cloudflare' }), - ).toBe('10.0.0.1'); + expect(resolveClientIp({ headers: h({}), peer: '10.0.0.1', trustProxy: 'cloudflare' })).toBe( + '10.0.0.1' + ); expect( resolveClientIp({ headers: h({ 'cf-connecting-ip': 'nope' }), peer: '10.0.0.1', trustProxy: 'cloudflare', - }), + }) ).toBe('10.0.0.1'); }); }); + 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', () => { + for (const xff of [ + '203.0.113.9, 198.51.100.7', + '1.2.3.4, 203.0.113.9, 198.51.100.7', + '203.0.113.9', + '', + ]) { + 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..d5eafe0 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 @@ -297,5 +308,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef } // Every hop was trusted, which means the outermost one is as far as the chain // goes — that address is the client. - return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer; + return full[0] ?? undefined ?? singleHeaderFallback() ?? peer; } From af8f47a716e7bb91ef2309358559708dbd24e93b Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 26 Sep 2026 22:28:14 -0500 Subject: [PATCH 2/2] Revert unrelated formatting churn Co-Authored-By: Claude --- packages/express/src/middleware.ts | 8 +++++--- packages/fastify/src/plugin.ts | 6 +++--- packages/nextjs/src/middleware.ts | 2 +- packages/webdecoy/src/client-ip.test.ts | 25 ++++++++++++++----------- packages/webdecoy/src/client-ip.ts | 2 +- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 66f758f..abd8e51 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -101,7 +101,7 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { res: Response, detection: SDKDetectionResponse, next: NextFunction, - decision: ProtectResult + decision: ProtectResult, ) => void; /** @@ -139,7 +139,9 @@ function resolveIP(req: Request, trustProxy: TrustedProxies | undefined): string } return ( - resolveClientIp({ headers: req.headers, peer, trustProxy }) ?? normalizeIp(peer) ?? '127.0.0.1' + resolveClientIp({ headers: req.headers, peer, trustProxy }) ?? + normalizeIp(peer) ?? + '127.0.0.1' ); } @@ -150,7 +152,7 @@ function defaultOnBlocked( req: Request, res: Response, detection: SDKDetectionResponse, - _next: NextFunction + _next: NextFunction, ): void { res.status(403).json({ error: 'Forbidden', diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index 1a21097..556e926 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -94,7 +94,7 @@ export interface WebDecoyPluginOptions extends ProtectOptions { req: FastifyRequest, reply: FastifyReply, detection: SDKDetectionResponse, - decision: ProtectResult + decision: ProtectResult, ) => void; /** @@ -141,7 +141,7 @@ function resolveIP(req: FastifyRequest, trustProxy: TrustedProxies | undefined): function defaultOnBlocked( req: FastifyRequest, reply: FastifyReply, - detection: SDKDetectionResponse + detection: SDKDetectionResponse, ): void { reply.status(403).send({ error: 'Forbidden', @@ -354,7 +354,7 @@ async function webdecoyPluginImpl( '[WebDecoy] HTML is being streamed, so the honeytoken link was not injected. ' + 'Render to a string, or embed the link yourself: ' + `' + 'style="position:absolute;left:-9999px">.', ); } return payload; diff --git a/packages/nextjs/src/middleware.ts b/packages/nextjs/src/middleware.ts index 3c765ac..66ed0a9 100644 --- a/packages/nextjs/src/middleware.ts +++ b/packages/nextjs/src/middleware.ts @@ -73,7 +73,7 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions { onBlocked?: ( req: NextRequest, detection: SDKDetectionResponse, - decision: ProtectResult + decision: ProtectResult, ) => NextResponse; /** diff --git a/packages/webdecoy/src/client-ip.test.ts b/packages/webdecoy/src/client-ip.test.ts index 6c17e9d..90fd69e 100644 --- a/packages/webdecoy/src/client-ip.test.ts +++ b/packages/webdecoy/src/client-ip.test.ts @@ -203,15 +203,15 @@ describe('resolveClientIp', () => { }); it('falls back to the peer when the header is missing or junk', () => { - expect(resolveClientIp({ headers: h({}), peer: '10.0.0.1', trustProxy: 'cloudflare' })).toBe( - '10.0.0.1' - ); + expect( + resolveClientIp({ headers: h({}), peer: '10.0.0.1', trustProxy: 'cloudflare' }), + ).toBe('10.0.0.1'); expect( resolveClientIp({ headers: h({ 'cf-connecting-ip': 'nope' }), peer: '10.0.0.1', trustProxy: 'cloudflare', - }) + }), ).toBe('10.0.0.1'); }); }); @@ -222,9 +222,9 @@ describe('resolveClientIp', () => { 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' - ); + 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', () => { @@ -232,22 +232,25 @@ describe('resolveClientIp', () => { }); it('answers exactly what a depth of 2 answers', () => { - for (const xff of [ + 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 }) + 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'); + expect(resolveClientIp({ headers: railway, trustProxy: 'railway' })).not.toBe( + '198.51.100.7', + ); }); }); diff --git a/packages/webdecoy/src/client-ip.ts b/packages/webdecoy/src/client-ip.ts index d5eafe0..062951e 100644 --- a/packages/webdecoy/src/client-ip.ts +++ b/packages/webdecoy/src/client-ip.ts @@ -308,5 +308,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef } // Every hop was trusted, which means the outermost one is as far as the chain // goes — that address is the client. - return full[0] ?? undefined ?? singleHeaderFallback() ?? peer; + return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer; }