fix(rate-limit): clarify trusted proxy documentation and improve client address handling

This commit is contained in:
RainbowBird
2026-07-31 22:58:05 +08:00
parent bbad277671
commit 71dd65cb99
2 changed files with 17 additions and 55 deletions
+7 -46
View File
@@ -17,8 +17,8 @@ interface RateLimitOptions {
keyGenerator?: (c: Context<HonoEnv>) => 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<HonoEnv>, 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
}
@@ -41,8 +41,8 @@ async function createApp(trustedProxy?: 'railway') {
return new Hono<HonoEnv>().route('/', routes)
}
async function listen(app: Hono<HonoEnv>) {
const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' })
async function listen(app: Hono<HonoEnv>, hostname = '127.0.0.1') {
const server = serve({ fetch: app.fetch, port: 0, hostname })
const port = await new Promise<number>((resolve) => {
server.once('listening', () => {
const address = server.address()
@@ -52,7 +52,7 @@ async function listen(app: Hono<HonoEnv>) {
})
return {
origin: `http://127.0.0.1:${port}`,
origin: `http://${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`,
close: () => new Promise<void>((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)