fix(rate-limit): clarify trusted proxy documentation and improve client address handling
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user