diff --git a/apps/server/src/middlewares/rate-limit.ts b/apps/server/src/middlewares/rate-limit.ts index 41debad98..f92f2121c 100644 --- a/apps/server/src/middlewares/rate-limit.ts +++ b/apps/server/src/middlewares/rate-limit.ts @@ -17,8 +17,8 @@ interface RateLimitOptions { keyGenerator?: (c: Context) => string /** * Reverse proxy whose client-address header is safe to use. The caller must - * select this only for a deployment that prevents direct public access to - * the application process. + * select this only when the deployment guarantees that the named proxy owns + * and overwrites that header before the request reaches the application. */ trustedProxy?: 'railway' /** @@ -87,55 +87,16 @@ export function rateLimiter(opts: RateLimitOptions) { } /** - * Returns Railway's canonical client address only when proxy trust is enabled - * and the request was received from an internal proxy address. - * - * Before: - * - a client could send `X-Forwarded-For: 203.0.113.1` and choose its bucket - * - * After: - * - `X-Real-IP` is used only when the explicit deployment setting and an - * internal socket establish the configured Railway proxy boundary + * Uses Railway's canonical client address only after the deployment explicitly + * opts into that trust boundary. Proxy transport details do not affect it. */ function getTrustedProxyClientAddress(c: Context, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined { if (trustedProxy !== 'railway') return undefined - try { - const remoteAddress = getConnInfo(c).remote?.address - const clientAddress = c.req.header('x-real-ip')?.trim() - if (!isRailwayInternalAddress(remoteAddress) || !clientAddress || isIP(clientAddress) === 0) - return undefined - - return clientAddress - } - catch { + const clientAddress = c.req.header('x-real-ip')?.trim() + if (!clientAddress || isIP(clientAddress) === 0) return undefined - } -} -/** - * Identifies address ranges Railway documents for internal proxy traffic. - * - * Before: - * - `203.0.113.42` - * - * After: - * - `100.64.0.42` - */ -function isRailwayInternalAddress(address: string | undefined): boolean { - if (!address) - return false - - const normalizedAddress = address.replace(/^::ffff:/i, '') - const octets = normalizedAddress.split('.').map(Number) - if (octets.length !== 4 || octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) - return false - - const [first, second] = octets - return first === 10 - || first === 100 - || first === 127 - || (first === 172 && second >= 16 && second <= 31) - || (first === 192 && second === 168) + return clientAddress } diff --git a/apps/server/src/routes/auth/auth-rate-limit.test.ts b/apps/server/src/routes/auth/auth-rate-limit.test.ts index a5b48e4fe..982386796 100644 --- a/apps/server/src/routes/auth/auth-rate-limit.test.ts +++ b/apps/server/src/routes/auth/auth-rate-limit.test.ts @@ -41,8 +41,8 @@ async function createApp(trustedProxy?: 'railway') { return new Hono().route('/', routes) } -async function listen(app: Hono) { - const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }) +async function listen(app: Hono, hostname = '127.0.0.1') { + const server = serve({ fetch: app.fetch, port: 0, hostname }) const port = await new Promise((resolve) => { server.once('listening', () => { const address = server.address() @@ -52,7 +52,7 @@ async function listen(app: Hono) { }) return { - origin: `http://127.0.0.1:${port}`, + origin: `http://${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`, close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()) }), @@ -81,13 +81,14 @@ describe('auth API rate limiting behind Railway', () => { } }) - it('uses the forwarded client IP behind a custom-domain gateway', async () => { + it('uses the forwarded client IP over an IPv6 gateway socket', async () => { // ROOT CAUSE: proxy trust was inferred from API_SERVER_URL, so moving the - // public custom domain to Caddy disabled X-Real-IP and merged every - // anonymous caller into the Caddy replica's socket-address bucket. - // AFTER: proxy trust is an explicit deployment setting rather than being - // inferred from the externally visible URL. - const server = await listen(await createApp('railway')) + // public custom domain to Caddy first disabled X-Real-IP. The replacement + // then allowed only IPv4 proxy sockets, while Railway connected Caddy to + // ts-api over private IPv6, so callers still shared the Caddy socket bucket. + // AFTER: the explicit deployment setting owns proxy trust; the middleware + // validates X-Real-IP without coupling it to the proxy transport family. + const server = await listen(await createApp('railway'), '::1') try { expect((await request(server.origin, '203.0.113.10')).status).toBe(200)