feat(rate-limit): implement RATE_LIMIT_TRUSTED_PROXY for Railway deployments and update related documentation

This commit is contained in:
RainbowBird
2026-07-31 22:16:01 +08:00
parent 81b8a4d5b4
commit 2a34a52fdc
7 changed files with 136 additions and 20 deletions
+4 -1
View File
@@ -14,6 +14,10 @@ STRIPE_WEBHOOK_SECRET=""
API_SERVER_URL="" 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. # 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), # 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" # 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`. # dropping PREVIOUS. See `apps/server/src/utils/envelope-crypto.ts`.
LLM_ROUTER_MASTER_KEY="" LLM_ROUTER_MASTER_KEY=""
# LLM_ROUTER_MASTER_KEY_PREVIOUS="" # LLM_ROUTER_MASTER_KEY_PREVIOUS=""
+7
View File
@@ -46,6 +46,13 @@ Default:
Set this when previewing or deploying admin UI to a different Cloudflare URL. 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) ## `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: 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:
+6 -1
View File
@@ -5,7 +5,7 @@ import { env, exit } from 'node:process'
import { useLogger } from '@guiiai/logg' import { useLogger } from '@guiiai/logg'
import { injeca } from 'injeca' 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 * 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'), 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 // Standalone auth UI base URL. The server keeps `/auth/*` as the historical
// entrypoint and redirects those requests here after ui-server-auth moved out // entrypoint and redirects those requests here after ui-server-auth moved out
// of the server image. // of the server image.
+10
View File
@@ -53,9 +53,19 @@ describe('parseEnv', () => {
'ai.moeru.airi-pocket', 'ai.moeru.airi-pocket',
'ai.moeru.airi-pro', 'ai.moeru.airi-pro',
]) ])
expect(env.RATE_LIMIT_TRUSTED_PROXY).toBeUndefined()
expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('line-one\nline-two') 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', () => { it('allows Apple auth to remain disabled when no Apple credentials are configured', () => {
const input = baseEnv() const input = baseEnv()
delete input.AUTH_APPLE_CLIENT_ID delete input.AUTH_APPLE_CLIENT_ID
+5 -6
View File
@@ -87,15 +87,15 @@ export function rateLimiter(opts: RateLimitOptions) {
} }
/** /**
* Returns Railway's canonical client address only for a request received from * Returns Railway's canonical client address only when proxy trust is enabled
* its internal proxy network. * and the request was received from an internal proxy address.
* *
* Before: * Before:
* - a client could send `X-Forwarded-For: 203.0.113.1` and choose its bucket * - a client could send `X-Forwarded-For: 203.0.113.1` and choose its bucket
* *
* After: * After:
* - `X-Real-IP` is used only when Railway's edge marker and an internal socket * - `X-Real-IP` is used only when the explicit deployment setting and an
* prove the request traversed the configured Railway proxy boundary * internal socket establish the configured Railway proxy boundary
*/ */
function getTrustedProxyClientAddress(c: Context<HonoEnv>, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined { function getTrustedProxyClientAddress(c: Context<HonoEnv>, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined {
if (trustedProxy !== 'railway') if (trustedProxy !== 'railway')
@@ -103,9 +103,8 @@ function getTrustedProxyClientAddress(c: Context<HonoEnv>, trustedProxy: RateLim
try { try {
const remoteAddress = getConnInfo(c).remote?.address const remoteAddress = getConnInfo(c).remote?.address
const edge = c.req.header('x-railway-edge')
const clientAddress = c.req.header('x-real-ip')?.trim() 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 undefined
return clientAddress 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()
}
})
})
+3 -12
View File
@@ -17,15 +17,6 @@ import { createElectronCallbackRelay } from './oidc/electron-callback'
import { createOIDCTokenAuthRoute } from './oidc/token-auth' import { createOIDCTokenAuthRoute } from './oidc/token-auth'
import { createAuthUiRoutes } from './ui-routes' 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 { export interface AuthRoutesDeps {
auth: AuthInstance auth: AuthInstance
db: Database db: Database
@@ -62,9 +53,9 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
.use('/api/auth/*', rateLimiter({ .use('/api/auth/*', rateLimiter({
max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'), max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'),
windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'), windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'),
// Railway documents `X-Real-IP` as the client address. Limit trust to // Proxy trust is a deployment boundary, not a property of the public
// its deployed domain; self-hosted instances keep socket-only buckets. // API URL. Custom domains and private gateways must opt in explicitly.
trustedProxy: usesRailwayEdge(deps.env.API_SERVER_URL) ? 'railway' : undefined, trustedProxy: deps.env.RATE_LIMIT_TRUSTED_PROXY,
metrics: deps.rateLimitMetrics, metrics: deps.rateLimitMetrics,
routeLabel: 'auth.api', routeLabel: 'auth.api',
})) }))