Skip to content

Commit 445be28

Browse files
cport1claude
andcommitted
feat: trustProxy: 'railway'
Railway's edge replaces any client-sent X-Forwarded-For 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 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 <noreply@anthropic.com>
1 parent 8126364 commit 445be28

7 files changed

Lines changed: 78 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`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.
13+
14+
### Changed
15+
16+
- **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.
17+
1018
## [0.17.0] - 2026-09-24
1119

1220
### Added

‎packages/express/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ app.use(
106106
);
107107
```
108108

109+
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.
110+
109111
## Custom Block Handler
110112

111113
```typescript

‎packages/express/src/middleware.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions {
5959
* Leave this unset and Express decides: `req.ip` already honours the app's own
6060
* `trust proxy` setting, which defaults to the socket address. Set it to
6161
* override that for WebDecoy alone — a number of trusted hops, `'cloudflare'`,
62-
* or CIDRs of your proxies.
62+
* `'railway'`, or CIDRs of your proxies.
6363
*
6464
* THIS CHANGED IN 0.12.0, and it is a behaviour change worth reading.
6565
*
@@ -101,7 +101,7 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions {
101101
res: Response,
102102
detection: SDKDetectionResponse,
103103
next: NextFunction,
104-
decision: ProtectResult,
104+
decision: ProtectResult
105105
) => void;
106106

107107
/**
@@ -139,9 +139,7 @@ function resolveIP(req: Request, trustProxy: TrustedProxies | undefined): string
139139
}
140140

141141
return (
142-
resolveClientIp({ headers: req.headers, peer, trustProxy }) ??
143-
normalizeIp(peer) ??
144-
'127.0.0.1'
142+
resolveClientIp({ headers: req.headers, peer, trustProxy }) ?? normalizeIp(peer) ?? '127.0.0.1'
145143
);
146144
}
147145

@@ -152,7 +150,7 @@ function defaultOnBlocked(
152150
req: Request,
153151
res: Response,
154152
detection: SDKDetectionResponse,
155-
_next: NextFunction,
153+
_next: NextFunction
156154
): void {
157155
res.status(403).json({
158156
error: 'Forbidden',

‎packages/fastify/src/plugin.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ export interface WebDecoyPluginOptions extends ProtectOptions {
6363
* Leave this unset and Fastify decides: `request.ip` already honours the
6464
* server's own `trustProxy` option, which defaults to the socket address. Set
6565
* it to override that for WebDecoy alone — a number of trusted hops,
66-
* `'cloudflare'`, or CIDRs of your proxies.
66+
* `'cloudflare'`, `'railway'`, or CIDRs of your proxies.
67+
*
68+
* Prefer this over Fastify's own option for a hop count: since Fastify 5.12 a
69+
* numeric server `trustProxy` trusts no hop at all (a count cannot tell a
70+
* proxy from a direct client), so `request.ip` stays the socket address.
6771
*
6872
* Fastify's default was already the safe one, so unlike the Express and
6973
* Next.js adapters nothing changes here in 0.12.0. The option exists so all
@@ -90,7 +94,7 @@ export interface WebDecoyPluginOptions extends ProtectOptions {
9094
req: FastifyRequest,
9195
reply: FastifyReply,
9296
detection: SDKDetectionResponse,
93-
decision: ProtectResult,
97+
decision: ProtectResult
9498
) => void;
9599

96100
/**
@@ -137,7 +141,7 @@ function resolveIP(req: FastifyRequest, trustProxy: TrustedProxies | undefined):
137141
function defaultOnBlocked(
138142
req: FastifyRequest,
139143
reply: FastifyReply,
140-
detection: SDKDetectionResponse,
144+
detection: SDKDetectionResponse
141145
): void {
142146
reply.status(403).send({
143147
error: 'Forbidden',
@@ -350,7 +354,7 @@ async function webdecoyPluginImpl(
350354
'[WebDecoy] HTML is being streamed, so the honeytoken link was not injected. ' +
351355
'Render to a string, or embed the link yourself: ' +
352356
`<a href="${ht.primaryPath}" aria-hidden="true" tabindex="-1" rel="nofollow noindex" ` +
353-
'style="position:absolute;left:-9999px">.</a>',
357+
'style="position:absolute;left:-9999px">.</a>'
354358
);
355359
}
356360
return payload;

‎packages/nextjs/src/middleware.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions {
4545
* Fastify adapters there is no safe "believe nothing" default here — `1` is
4646
* correct on Vercel and on any single-proxy deployment. Behind a CDN in front
4747
* of your platform, set `2`. Behind Cloudflare with the origin locked to it,
48-
* `'cloudflare'` is stronger than counting.
48+
* `'cloudflare'` is stronger than counting. On Railway, set `'railway'`: its
49+
* edge writes two entries, so the default of `1` names the edge, not the
50+
* visitor.
4951
*/
5052
trustProxy?: TrustedProxies;
5153

@@ -71,7 +73,7 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions {
7173
onBlocked?: (
7274
req: NextRequest,
7375
detection: SDKDetectionResponse,
74-
decision: ProtectResult,
76+
decision: ProtectResult
7577
) => NextResponse;
7678

7779
/**

‎packages/webdecoy/src/client-ip.test.ts‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,19 +203,54 @@ describe('resolveClientIp', () => {
203203
});
204204

205205
it('falls back to the peer when the header is missing or junk', () => {
206-
expect(
207-
resolveClientIp({ headers: h({}), peer: '10.0.0.1', trustProxy: 'cloudflare' }),
208-
).toBe('10.0.0.1');
206+
expect(resolveClientIp({ headers: h({}), peer: '10.0.0.1', trustProxy: 'cloudflare' })).toBe(
207+
'10.0.0.1'
208+
);
209209
expect(
210210
resolveClientIp({
211211
headers: h({ 'cf-connecting-ip': 'nope' }),
212212
peer: '10.0.0.1',
213213
trustProxy: 'cloudflare',
214-
}),
214+
})
215215
).toBe('10.0.0.1');
216216
});
217217
});
218218

219+
describe('railway', () => {
220+
// The shape Railway's edge produces: whatever the client sent is replaced by
221+
// `<client>, <edge>`, and the socket peer is an internal address.
222+
const railway = h({ 'x-forwarded-for': '203.0.113.9, 198.51.100.7' });
223+
224+
it('reads the client two entries from the right', () => {
225+
expect(resolveClientIp({ headers: railway, peer: '100.64.0.2', trustProxy: 'railway' })).toBe(
226+
'203.0.113.9'
227+
);
228+
});
229+
230+
it('works without a peer, as in edge middleware', () => {
231+
expect(resolveClientIp({ headers: railway, trustProxy: 'railway' })).toBe('203.0.113.9');
232+
});
233+
234+
it('answers exactly what a depth of 2 answers', () => {
235+
for (const xff of [
236+
'203.0.113.9, 198.51.100.7',
237+
'1.2.3.4, 203.0.113.9, 198.51.100.7',
238+
'203.0.113.9',
239+
'',
240+
]) {
241+
const headers = h(xff ? { 'x-forwarded-for': xff } : {});
242+
expect(resolveClientIp({ headers, peer: '100.64.0.2', trustProxy: 'railway' })).toBe(
243+
resolveClientIp({ headers, peer: '100.64.0.2', trustProxy: 2 })
244+
);
245+
}
246+
});
247+
248+
it('does not name the edge, which a depth of 1 would', () => {
249+
expect(resolveClientIp({ headers: railway, trustProxy: 1 })).toBe('198.51.100.7');
250+
expect(resolveClientIp({ headers: railway, trustProxy: 'railway' })).not.toBe('198.51.100.7');
251+
});
252+
});
253+
219254
describe('a CIDR list', () => {
220255
const trustProxy = ['10.0.0.0/8', '172.16.0.0/12'];
221256

‎packages/webdecoy/src/client-ip.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,16 @@
4040
* - `'cloudflare'` — use `CF-Connecting-IP`. Only meaningful if the origin is
4141
* unreachable except through Cloudflare, since the header is otherwise just
4242
* another thing a client can send.
43+
* - `'railway'` — the app runs on Railway with nothing else in front. Railway's
44+
* edge replaces any `X-Forwarded-For` the client sent with exactly
45+
* `<client>, <edge>`, so the client is two entries from the right. The
46+
* common guess of `1` names Railway's edge for every visitor. With a CDN in
47+
* front of Railway, configure for the CDN instead (`'cloudflare'`).
4348
* - `string[]` — CIDRs (or bare addresses) of the proxies you run. The chain is
4449
* walked right to left and the first address that isn't one of yours is the
4550
* client. Use this when the depth varies.
4651
*/
47-
export type TrustedProxies = false | number | 'cloudflare' | string[];
52+
export type TrustedProxies = false | number | 'cloudflare' | 'railway' | string[];
4853

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

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

265276
// 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
297308
}
298309
// Every hop was trusted, which means the outermost one is as far as the chain
299310
// goes — that address is the client.
300-
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
311+
return full[0] ?? undefined ?? singleHeaderFallback() ?? peer;
301312
}

0 commit comments

Comments
 (0)