feat(rate-limit): implement RATE_LIMIT_TRUSTED_PROXY for Railway deployments and update related documentation
This commit is contained in:
+4
-1
@@ -14,6 +14,10 @@ STRIPE_WEBHOOK_SECRET=""
|
||||
|
||||
API_SERVER_URL=""
|
||||
|
||||
# Trust Railway's canonical X-Real-IP only when this service is deployed behind
|
||||
# Railway/Caddy and cannot be reached through an untrusted direct proxy.
|
||||
# RATE_LIMIT_TRUSTED_PROXY="railway"
|
||||
|
||||
# Comma-separated browser origins for CORS (/api/*) and Stripe return URLs.
|
||||
# Required when the Capacitor dev server uses a LAN IP (see ios/App/App/capacitor.config.json),
|
||||
# e.g. ADDITIONAL_TRUSTED_ORIGINS="https://10.0.0.129:5273,https://198.18.0.1:5273"
|
||||
@@ -34,4 +38,3 @@ API_SERVER_URL=""
|
||||
# dropping PREVIOUS. See `apps/server/src/utils/envelope-crypto.ts`.
|
||||
LLM_ROUTER_MASTER_KEY=""
|
||||
# LLM_ROUTER_MASTER_KEY_PREVIOUS=""
|
||||
|
||||
|
||||
@@ -46,6 +46,13 @@ Default:
|
||||
|
||||
Set this when previewing or deploying admin UI to a different Cloudflare URL.
|
||||
|
||||
## `RATE_LIMIT_TRUSTED_PROXY`
|
||||
|
||||
Keep this unset for local and self-hosted deployments. Set
|
||||
`RATE_LIMIT_TRUSTED_PROXY=railway` when the API runs behind the trusted
|
||||
Railway/Caddy boundary so anonymous auth requests are keyed by Railway's
|
||||
canonical `X-Real-IP` instead of the gateway socket address.
|
||||
|
||||
## `ADDITIONAL_TRUSTED_ORIGINS` (LAN / Capacitor dev)
|
||||
|
||||
When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `apps/server/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example:
|
||||
|
||||
@@ -5,7 +5,7 @@ import { env, exit } from 'node:process'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { injeca } from 'injeca'
|
||||
import { check, integer, maxValue, minValue, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot'
|
||||
import { check, integer, maxValue, minValue, nonEmpty, object, optional, parse, picklist, pipe, string, transform } from 'valibot'
|
||||
|
||||
/**
|
||||
* Parses `ADDITIONAL_TRUSTED_ORIGINS`: comma-separated absolute origins used for
|
||||
@@ -80,6 +80,11 @@ const EnvSchema = object({
|
||||
|
||||
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
|
||||
|
||||
// Trust Railway's canonical client-IP headers only when the application is
|
||||
// deployed behind a private reverse-proxy boundary. Keep unset for direct or
|
||||
// self-hosted deployments so callers cannot choose their own rate-limit key.
|
||||
RATE_LIMIT_TRUSTED_PROXY: optional(picklist(['railway'])),
|
||||
|
||||
// Standalone auth UI base URL. The server keeps `/auth/*` as the historical
|
||||
// entrypoint and redirects those requests here after ui-server-auth moved out
|
||||
// of the server image.
|
||||
|
||||
@@ -53,9 +53,19 @@ describe('parseEnv', () => {
|
||||
'ai.moeru.airi-pocket',
|
||||
'ai.moeru.airi-pro',
|
||||
])
|
||||
expect(env.RATE_LIMIT_TRUSTED_PROXY).toBeUndefined()
|
||||
expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('line-one\nline-two')
|
||||
})
|
||||
|
||||
it('parses an explicit Railway rate-limit proxy boundary', () => {
|
||||
const env = parseEnv({
|
||||
...baseEnv(),
|
||||
RATE_LIMIT_TRUSTED_PROXY: 'railway',
|
||||
})
|
||||
|
||||
expect(env.RATE_LIMIT_TRUSTED_PROXY).toBe('railway')
|
||||
})
|
||||
|
||||
it('allows Apple auth to remain disabled when no Apple credentials are configured', () => {
|
||||
const input = baseEnv()
|
||||
delete input.AUTH_APPLE_CLIENT_ID
|
||||
|
||||
@@ -87,15 +87,15 @@ export function rateLimiter(opts: RateLimitOptions) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Railway's canonical client address only for a request received from
|
||||
* its internal proxy network.
|
||||
* 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 Railway's edge marker and an internal socket
|
||||
* prove the request traversed the configured Railway proxy boundary
|
||||
* - `X-Real-IP` is used only when the explicit deployment setting and an
|
||||
* internal socket establish the configured Railway proxy boundary
|
||||
*/
|
||||
function getTrustedProxyClientAddress(c: Context<HonoEnv>, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined {
|
||||
if (trustedProxy !== 'railway')
|
||||
@@ -103,9 +103,8 @@ function getTrustedProxyClientAddress(c: Context<HonoEnv>, trustedProxy: RateLim
|
||||
|
||||
try {
|
||||
const remoteAddress = getConnInfo(c).remote?.address
|
||||
const edge = c.req.header('x-railway-edge')
|
||||
const clientAddress = c.req.header('x-real-ip')?.trim()
|
||||
if (!isRailwayInternalAddress(remoteAddress) || !edge?.startsWith('railway/') || !clientAddress || isIP(clientAddress) === 0)
|
||||
if (!isRailwayInternalAddress(remoteAddress) || !clientAddress || isIP(clientAddress) === 0)
|
||||
return undefined
|
||||
|
||||
return clientAddress
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ConfigKVService } from '../../services/adapters/config-kv'
|
||||
import type { HonoEnv } from '../../types/hono'
|
||||
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createAuthRoutes } from '.'
|
||||
|
||||
function createConfigKV(): ConfigKVService {
|
||||
const values: Record<string, number> = {
|
||||
AUTH_RATE_LIMIT_MAX: 1,
|
||||
AUTH_RATE_LIMIT_WINDOW_SEC: 60,
|
||||
}
|
||||
|
||||
return {
|
||||
get: vi.fn(async (key: string) => values[key]),
|
||||
getOrThrow: vi.fn(async (key: string) => values[key]),
|
||||
getOptional: vi.fn(async (key: string) => values[key] ?? null),
|
||||
set: vi.fn(),
|
||||
} as any
|
||||
}
|
||||
|
||||
async function createApp(trustedProxy?: 'railway') {
|
||||
const routes = await createAuthRoutes({
|
||||
auth: {
|
||||
handler: vi.fn(async () => new Response(null, { status: 200 })),
|
||||
api: { getSession: vi.fn(async () => null) },
|
||||
} as any,
|
||||
db: {} as any,
|
||||
env: {
|
||||
API_SERVER_URL: 'https://api.airi.build',
|
||||
AUTH_UI_URL: 'https://accounts.airi.build/ui',
|
||||
ADDITIONAL_TRUSTED_ORIGINS: [],
|
||||
RATE_LIMIT_TRUSTED_PROXY: trustedProxy,
|
||||
} as any,
|
||||
configKV: createConfigKV(),
|
||||
rateLimitMetrics: null,
|
||||
})
|
||||
|
||||
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' })
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.once('listening', () => {
|
||||
const address = server.address()
|
||||
if (address && typeof address === 'object')
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close(error => error ? reject(error) : resolve())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function request(origin: string, clientAddress: string) {
|
||||
return fetch(`${origin}/api/auth/get-session`, {
|
||||
headers: {
|
||||
'connection': 'close',
|
||||
'x-real-ip': clientAddress,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('auth API rate limiting behind Railway', () => {
|
||||
it('ignores forwarded client IPs unless proxy trust is explicitly enabled', async () => {
|
||||
const server = await listen(await createApp())
|
||||
|
||||
try {
|
||||
expect((await request(server.origin, '203.0.113.20')).status).toBe(200)
|
||||
expect((await request(server.origin, '203.0.113.21')).status).toBe(429)
|
||||
}
|
||||
finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the forwarded client IP behind a custom-domain gateway', 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'))
|
||||
|
||||
try {
|
||||
expect((await request(server.origin, '203.0.113.10')).status).toBe(200)
|
||||
expect((await request(server.origin, '203.0.113.11')).status).toBe(200)
|
||||
expect((await request(server.origin, '203.0.113.11')).status).toBe(429)
|
||||
}
|
||||
finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -17,15 +17,6 @@ import { createElectronCallbackRelay } from './oidc/electron-callback'
|
||||
import { createOIDCTokenAuthRoute } from './oidc/token-auth'
|
||||
import { createAuthUiRoutes } from './ui-routes'
|
||||
|
||||
function usesRailwayEdge(apiServerUrl: string): boolean {
|
||||
try {
|
||||
return new URL(apiServerUrl).hostname.endsWith('.up.railway.app')
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthRoutesDeps {
|
||||
auth: AuthInstance
|
||||
db: Database
|
||||
@@ -62,9 +53,9 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
|
||||
.use('/api/auth/*', rateLimiter({
|
||||
max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'),
|
||||
windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'),
|
||||
// Railway documents `X-Real-IP` as the client address. Limit trust to
|
||||
// its deployed domain; self-hosted instances keep socket-only buckets.
|
||||
trustedProxy: usesRailwayEdge(deps.env.API_SERVER_URL) ? 'railway' : undefined,
|
||||
// Proxy trust is a deployment boundary, not a property of the public
|
||||
// API URL. Custom domains and private gateways must opt in explicitly.
|
||||
trustedProxy: deps.env.RATE_LIMIT_TRUSTED_PROXY,
|
||||
metrics: deps.rateLimitMetrics,
|
||||
routeLabel: 'auth.api',
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user