From 2a34a52fdc179cf698ef324c776e7eeedd119352 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 31 Jul 2026 22:15:35 +0800 Subject: [PATCH] feat(rate-limit): implement RATE_LIMIT_TRUSTED_PROXY for Railway deployments and update related documentation --- apps/server/.env | 5 +- apps/server/README.md | 7 ++ apps/server/src/libs/env.ts | 7 +- apps/server/src/libs/tests/env.test.ts | 10 ++ apps/server/src/middlewares/rate-limit.ts | 11 +- .../src/routes/auth/auth-rate-limit.test.ts | 101 ++++++++++++++++++ apps/server/src/routes/auth/index.ts | 15 +-- 7 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/routes/auth/auth-rate-limit.test.ts diff --git a/apps/server/.env b/apps/server/.env index 9075a9bd3..73fad2f69 100644 --- a/apps/server/.env +++ b/apps/server/.env @@ -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="" - diff --git a/apps/server/README.md b/apps/server/README.md index f60f07a3a..9a48038aa 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -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: diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 364931f36..61f3c616c 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -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. diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 67a135a0d..1cf440e52 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -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 diff --git a/apps/server/src/middlewares/rate-limit.ts b/apps/server/src/middlewares/rate-limit.ts index dea4777b2..41debad98 100644 --- a/apps/server/src/middlewares/rate-limit.ts +++ b/apps/server/src/middlewares/rate-limit.ts @@ -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, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined { if (trustedProxy !== 'railway') @@ -103,9 +103,8 @@ function getTrustedProxyClientAddress(c: Context, 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 diff --git a/apps/server/src/routes/auth/auth-rate-limit.test.ts b/apps/server/src/routes/auth/auth-rate-limit.test.ts new file mode 100644 index 000000000..a5b48e4fe --- /dev/null +++ b/apps/server/src/routes/auth/auth-rate-limit.test.ts @@ -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 = { + 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().route('/', routes) +} + +async function listen(app: Hono) { + const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }) + const port = await new Promise((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((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() + } + }) +}) diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 562f2fc61..0c2c2ea3d 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -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', }))