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=""
|
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=""
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user