feat(server): env-based trusted origins for Capacitor dev (#1763)

## Description

Adds optional env **`ADDITIONAL_TRUSTED_ORIGINS`**: comma-separated
browser origins that are trusted for **CORS (`/api/*`)**, **Stripe
return URLs**, **Better Auth `trustedOrigins`**, and **dynamic web OIDC
redirect URIs**.

LAN / non-localhost Capacitor dev (e.g. Pocket + Vite on
`https://10.x:5273`) no longer relies on broad private-IP regex;
operators list exact origins in `.env.local` and restart the API server
after changes.

## Linked Issues

<!-- N/A -->

## Additional Context

Pocket iOS dev workflow: `cap`/`capacitor.config` often points at a LAN
HTTPS origin; without this allowlist the API rejects those
`Origin`/`Referer`/`redirect_uri` bases. Review can stay focused on
**`apps/server/src/libs/env.ts`**,
**`apps/server/src/utils/origin.ts`**, and wiring in **`app.ts`**,
**Stripe**, **auth routes**.
This commit is contained in:
Lulu
2026-05-14 16:17:13 +08:00
committed by GitHub
parent ab7ca0ef58
commit 98b1b9c34f
11 changed files with 205 additions and 30 deletions
+4
View File
@@ -15,6 +15,10 @@ STRIPE_WEBHOOK_SECRET=""
CLIENT_URL=""
API_SERVER_URL=""
# 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"
# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
GATEWAY_BASE_URL="http://localhost:18080"
+8
View File
@@ -25,3 +25,11 @@ For local observability infrastructure, use:
```sh
docker compose -f apps/server/docker-compose.otel.yml up -d
```
## `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:
`ADDITIONAL_TRUSTED_ORIGINS=https://10.0.0.129:5273,https://198.18.0.1:5273`
Restart the API server after changing this variable.
+1 -1
View File
@@ -104,7 +104,7 @@ export async function buildApp(deps: AppDeps) {
.use(
'/api/*',
cors({
origin: origin => getTrustedOrigin(origin),
origin: origin => getTrustedOrigin(origin, deps.env.ADDITIONAL_TRUSTED_ORIGINS),
credentials: true,
}),
)
+5
View File
@@ -139,6 +139,7 @@ describe('ensureDynamicFirstPartyRedirectUri', () => {
await ensureDynamicFirstPartyRedirectUri(
db as any,
new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fpreview.kwaa.workers.dev%2Fauth%2Fcallback'),
[],
)
expect(setCalls).toHaveLength(1)
@@ -173,6 +174,7 @@ describe('ensureDynamicFirstPartyRedirectUri', () => {
await ensureDynamicFirstPartyRedirectUri(
db as any,
new Request('https://airi-server-dev.up.railway.app/api/auth/oauth2/authorize?client_id=airi-stage-electron&redirect_uri=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fauth%2Foidc%2Felectron-callback'),
[],
)
expect(setCalls).toHaveLength(1)
@@ -192,16 +194,19 @@ describe('ensureDynamicFirstPartyRedirectUri', () => {
await ensureDynamicFirstPartyRedirectUri(
db as any,
new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fevil.example%2Fauth%2Fcallback'),
[],
)
await ensureDynamicFirstPartyRedirectUri(
db as any,
new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fairi.moeru.ai%2Fother-path'),
[],
)
await ensureDynamicFirstPartyRedirectUri(
db as any,
new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-electron&redirect_uri=https%3A%2F%2Fother.example%2Fapi%2Fauth%2Foidc%2Felectron-callback'),
[],
)
expect(db.select).not.toHaveBeenCalled()
+4 -3
View File
@@ -89,13 +89,13 @@ function buildWebRedirectUris(env: Env): string[] {
return [...uris]
}
function buildTrustedWebRedirectUri(redirectUri: string): string | null {
function buildTrustedWebRedirectUri(redirectUri: string, additionalTrustedOrigins: readonly string[]): string | null {
try {
const parsed = new URL(redirectUri)
if (parsed.pathname !== '/auth/callback')
return null
const trustedOrigin = getTrustedOrigin(parsed.origin)
const trustedOrigin = getTrustedOrigin(parsed.origin, additionalTrustedOrigins)
if (!trustedOrigin)
return null
@@ -202,6 +202,7 @@ export function getTrustedOIDCClientIds(): string[] {
export async function ensureDynamicFirstPartyRedirectUri(
db: Database,
request: Request,
additionalTrustedOrigins: readonly string[],
): Promise<void> {
const url = new URL(request.url)
const clientId = url.searchParams.get('client_id')
@@ -214,7 +215,7 @@ export async function ensureDynamicFirstPartyRedirectUri(
switch (clientId) {
case OIDC_CLIENT_ID_WEB:
normalizedRedirectUri = buildTrustedWebRedirectUri(redirectUri)
normalizedRedirectUri = buildTrustedWebRedirectUri(redirectUri, additionalTrustedOrigins)
break
case OIDC_CLIENT_ID_ELECTRON:
normalizedRedirectUri = buildTrustedElectronRedirectUri(request, redirectUri)
+47 -13
View File
@@ -1,23 +1,57 @@
import { describe, expect, it } from 'vitest'
import { parseEnv } from './env'
import { parseAdditionalTrustedOriginsEnv, parseEnv } from './env'
function baseEnv(): Record<string, string> {
return {
DATABASE_URL: 'postgres://example',
REDIS_URL: 'redis://example',
BETTER_AUTH_SECRET: 'test-secret-at-least-32-characters-long',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
GATEWAY_BASE_URL: 'http://localhost:18080',
DEFAULT_CHAT_MODEL: 'openai/gpt-5-mini',
DEFAULT_TTS_MODEL: 'microsoft/v1',
}
}
describe('parseAdditionalTrustedOriginsEnv', () => {
it('normalizes comma-separated origins and dedupes', () => {
expect(parseAdditionalTrustedOriginsEnv('')).toEqual([])
expect(parseAdditionalTrustedOriginsEnv(' https://10.0.0.129:5273/ , https://198.18.0.1:5273 ')).toEqual([
'https://10.0.0.129:5273',
'https://198.18.0.1:5273',
])
expect(parseAdditionalTrustedOriginsEnv('https://x.test:5273/,https://x.test:5273')).toEqual([
'https://x.test:5273',
])
})
it('throws on invalid segments', () => {
expect(() => parseAdditionalTrustedOriginsEnv('not-a-url')).toThrow(/invalid URL origin segment/)
})
})
describe('parseEnv', () => {
it('parses the required auth and infrastructure environment variables', () => {
const env = parseEnv({
DATABASE_URL: 'postgres://example',
REDIS_URL: 'redis://example',
BETTER_AUTH_SECRET: 'test-secret-at-least-32-characters-long',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
GATEWAY_BASE_URL: 'https://gateway.example',
DEFAULT_CHAT_MODEL: 'openai/gpt-4o-mini',
DEFAULT_TTS_MODEL: 'openai/gpt-4o-mini-tts',
})
const env = parseEnv(baseEnv())
expect(env.DATABASE_URL).toBe('postgres://example')
expect(env.REDIS_URL).toBe('redis://example')
expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([])
})
it('parses ADDITIONAL_TRUSTED_ORIGINS into a normalized origin list', () => {
const env = parseEnv({
...baseEnv(),
ADDITIONAL_TRUSTED_ORIGINS: 'https://10.0.0.129:5273/, https://198.18.0.1:5273',
})
expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([
'https://10.0.0.129:5273',
'https://198.18.0.1:5273',
])
})
})
+53
View File
@@ -6,6 +6,49 @@ import { useLogger } from '@guiiai/logg'
import { injeca } from 'injeca'
import { integer, maxValue, minValue, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot'
import { DEFAULT_BILLING_EVENTS_STREAM } from '../utils/redis-keys'
/**
* Parses `ADDITIONAL_TRUSTED_ORIGINS`: comma-separated absolute origins used for
* CORS (`/api/*`) and request-derived trusted bases (e.g. Stripe return URLs).
* Each segment is normalized via `URL.origin` so trailing slashes are stripped.
*
* Before:
* - `" https://10.0.0.129:5273/ , https://198.18.0.1:5273 "`
*
* After:
* - `["https://10.0.0.129:5273", "https://198.18.0.1:5273"]`
*/
export function parseAdditionalTrustedOriginsEnv(raw: string): string[] {
const trimmed = raw.trim()
if (!trimmed)
return []
const seen = new Set<string>()
const out: string[] = []
for (const part of trimmed.split(',')) {
const entry = part.trim()
if (!entry)
continue
let normalized: string
try {
normalized = new URL(entry).origin
}
catch {
throw new TypeError(`ADDITIONAL_TRUSTED_ORIGINS: invalid URL origin segment "${entry}"`)
}
if (!seen.has(normalized)) {
seen.add(normalized)
out.push(normalized)
}
}
return out
}
function optionalIntegerFromString(defaultValue: number, envKey: string, minimum: number) {
return optional(
pipe(
@@ -38,6 +81,16 @@ const EnvSchema = object({
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
// Comma-separated exact origins (e.g. Capacitor dev server `https://10.x:5273`).
// Prefer this over broad private-IP regex heuristics in production-like configs.
ADDITIONAL_TRUSTED_ORIGINS: optional(
pipe(
string(),
transform(raw => parseAdditionalTrustedOriginsEnv(raw)),
),
'',
),
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')),
+1 -1
View File
@@ -135,7 +135,7 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
routeLabel: 'auth.api',
}))
.use('/api/auth/oauth2/authorize', async (c, next) => {
await ensureDynamicFirstPartyRedirectUri(deps.db, c.req.raw)
await ensureDynamicFirstPartyRedirectUri(deps.db, c.req.raw, deps.env.ADDITIONAL_TRUSTED_ORIGINS)
await next()
})
.route('/api/auth', createOIDCTokenAuthRoute(deps))
+2 -2
View File
@@ -174,7 +174,7 @@ export function createStripeRoutes(
const customer = await stripeService.getCustomerByUserId(user.id)
const stripeCustomerId = customer?.stripeCustomerId
const redirectBase = resolveTrustedRequestOrigin(c.req.raw)
const redirectBase = resolveTrustedRequestOrigin(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS)
if (!redirectBase) {
throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN')
}
@@ -249,7 +249,7 @@ export function createStripeRoutes(
if (!customer)
throw createBadRequestError('No billing account found', 'NO_CUSTOMER')
const portalReturnBase = resolveTrustedRequestOrigin(c.req.raw)
const portalReturnBase = resolveTrustedRequestOrigin(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS)
if (!portalReturnBase) {
throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN')
}
+53 -9
View File
@@ -15,6 +15,9 @@ const TRUSTED_EXACT_ORIGINS = [
'https://airi.moeru.ai', // Production
]
// NOTICE:
// Private LAN / CGNAT-style dev hosts (e.g. https://10.x:5273 from cap-vite) are NOT matched
// by regex here — list them explicitly via env `ADDITIONAL_TRUSTED_ORIGINS` (see env.ts).
const TRUSTED_ORIGIN_PATTERNS = [
// Localhost dev (any port)
/^http:\/\/localhost(:\d+)?$/,
@@ -27,30 +30,54 @@ const TRUSTED_ORIGIN_PATTERNS = [
/^https:\/\/.*\.kwaa\.workers\.dev$/,
]
export function getTrustedOrigin(origin: string): string {
/**
* Returns `origin` when it matches built-in trust rules or `additionalTrustedOrigins`.
*
* Use when:
* - CORS allowlists (`/api/*`) or Stripe redirect base resolution need the same rules as Better Auth.
*
* Expects:
* - `origin` is the raw `Origin` header value or `new URL(referer).origin`.
* - `additionalTrustedOrigins` entries are normalized origins (see {@link parseAdditionalTrustedOriginsEnv}).
*
* Returns:
* - The same origin string when trusted, or `''` when not trusted.
*/
export function getTrustedOrigin(origin: string, additionalTrustedOrigins: readonly string[] = []): string {
if (!origin)
return origin
if (TRUSTED_EXACT_ORIGINS.includes(origin))
return origin
if (additionalTrustedOrigins.includes(origin))
return origin
if (TRUSTED_ORIGIN_PATTERNS.some(pattern => pattern.test(origin)))
return origin
return ''
}
export function resolveTrustedRequestOrigin(request: Request): string | undefined {
/**
* Resolves a trusted browser origin from `Referer` (preferred) or `Origin`.
*
* Expects:
* - Same trust inputs as {@link getTrustedOrigin}.
*
* Returns:
* - The trusted origin string, or `undefined` when neither header yields a trusted origin.
*/
export function resolveTrustedRequestOrigin(
request: Request,
additionalTrustedOrigins: readonly string[] = [],
): string | undefined {
const refererOrigin = getOriginFromUrl(request.headers.get('referer') ?? '')
if (refererOrigin) {
const trustedRefererOrigin = getTrustedOrigin(refererOrigin)
const trustedRefererOrigin = getTrustedOrigin(refererOrigin, additionalTrustedOrigins)
if (trustedRefererOrigin) {
return trustedRefererOrigin
}
}
const requestOrigin = request.headers.get('origin') ?? ''
const trustedRequestOrigin = getTrustedOrigin(requestOrigin)
const trustedRequestOrigin = getTrustedOrigin(requestOrigin, additionalTrustedOrigins)
if (trustedRequestOrigin) {
return trustedRequestOrigin
}
@@ -75,19 +102,36 @@ const ALWAYS_TRUSTED_AUTH_ORIGINS = [
'http://127.0.0.1:*',
]
export function getAuthTrustedOrigins(env: Pick<Env, 'API_SERVER_URL'>, request?: Request): string[] {
/**
* Builds the origin list passed to Better Auth `trustedOrigins` (and related flows).
*
* Expects:
* - `env.API_SERVER_URL` and parsed `env.ADDITIONAL_TRUSTED_ORIGINS`.
* - Optional `request` so the caller's Origin/Referer can be merged when known.
*
* Returns:
* - De-duplicated origins in insertion order (API URL, env extras, localhost wildcards, then request-derived).
*/
export function getAuthTrustedOrigins(
env: Pick<Env, 'API_SERVER_URL' | 'ADDITIONAL_TRUSTED_ORIGINS'>,
request?: Request,
): string[] {
const origins = new Set<string>()
const apiServerOrigin = getOriginFromUrl(env.API_SERVER_URL)
if (apiServerOrigin) {
origins.add(apiServerOrigin)
}
for (const origin of env.ADDITIONAL_TRUSTED_ORIGINS) {
origins.add(origin)
}
for (const origin of ALWAYS_TRUSTED_AUTH_ORIGINS) {
origins.add(origin)
}
if (request) {
const requestOrigin = resolveTrustedRequestOrigin(request)
const requestOrigin = resolveTrustedRequestOrigin(request, env.ADDITIONAL_TRUSTED_ORIGINS)
if (requestOrigin) {
origins.add(requestOrigin)
}
+27 -1
View File
@@ -12,6 +12,17 @@ describe('origin utils', () => {
expect(getTrustedOrigin('https://127.0.0.1:5273')).toBe('https://127.0.0.1:5273')
})
it('rejects private LAN Vite dev origins unless listed in ADDITIONAL_TRUSTED_ORIGINS', () => {
expect(getTrustedOrigin('https://10.0.0.129:5273')).toBe('')
expect(getTrustedOrigin('https://198.18.0.1:5273')).toBe('')
expect(getTrustedOrigin('https://192.168.1.5:5273')).toBe('')
const extra = ['https://10.0.0.129:5273', 'https://198.18.0.1:5273', 'https://192.168.1.5:5273']
expect(getTrustedOrigin('https://10.0.0.129:5273', extra)).toBe('https://10.0.0.129:5273')
expect(getTrustedOrigin('https://198.18.0.1:5273', extra)).toBe('https://198.18.0.1:5273')
expect(getTrustedOrigin('https://192.168.1.5:5273', extra)).toBe('https://192.168.1.5:5273')
})
it('rejects untrusted origins', () => {
expect(getTrustedOrigin('https://example.com')).toBe('')
})
@@ -44,11 +55,26 @@ describe('origin utils', () => {
},
})
expect(getAuthTrustedOrigins({ API_SERVER_URL: 'https://api.airi.moeru.ai' } as any, request)).toEqual([
expect(getAuthTrustedOrigins({
API_SERVER_URL: 'https://api.airi.moeru.ai',
ADDITIONAL_TRUSTED_ORIGINS: [],
}, request)).toEqual([
'https://api.airi.moeru.ai',
'http://localhost:*',
'http://127.0.0.1:*',
'http://localhost:5173',
])
})
it('includes ADDITIONAL_TRUSTED_ORIGINS in Better Auth trustedOrigins list', () => {
expect(getAuthTrustedOrigins({
API_SERVER_URL: 'https://api.airi.moeru.ai',
ADDITIONAL_TRUSTED_ORIGINS: ['https://10.0.0.129:5273'],
})).toEqual([
'https://api.airi.moeru.ai',
'https://10.0.0.129:5273',
'http://localhost:*',
'http://127.0.0.1:*',
])
})
})