Skip to content

Commit 4440001

Browse files
committed
fix(auth): resolve CORS errors for self-hosted deployments behind reverse proxies
- auth client now uses browser origin first, falling back to NEXT_PUBLIC_APP_URL - socket client falls back to page origin when served from non-localhost (assumes /socket.io is proxied) - add TRUSTED_ORIGINS env var to extend Better Auth trustedOrigins (apex+www, alias hostnames) - warn at startup when NEXT_PUBLIC_APP_URL is localhost in production - preprocess empty NEXT_PUBLIC_SOCKET_URL so docker-compose ${VAR:-} works - migrate remaining uuid/nanoid/randomUUID usages to @sim/utils generateId/generateShortId - extend generateShortId with optional alphabet param (rejection sampling) - document TRUSTED_ORIGINS in .env.example, docker-compose.prod.yml, and helm values.yaml Fixes #1243
1 parent dafeaaa commit 4440001

18 files changed

Lines changed: 306 additions & 42 deletions

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ BETTER_AUTH_URL=http://localhost:3000
1111
# NextJS (Required)
1212
NEXT_PUBLIC_APP_URL=http://localhost:3000
1313
# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL
14+
# TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins.
1415

1516
# Security (Required)
1617
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables

apps/sim/lib/auth/auth-client.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,11 @@ import { createAuthClient } from 'better-auth/react'
1212
import type { auth } from '@/lib/auth'
1313
import { env } from '@/lib/core/config/env'
1414
import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/feature-flags'
15-
import { getBaseUrl } from '@/lib/core/utils/urls'
15+
import { getBaseUrl, getBrowserOrigin } from '@/lib/core/utils/urls'
1616
import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider'
1717

1818
function getAuthBaseUrl(): string {
19-
try {
20-
return getBaseUrl()
21-
} catch (e) {
22-
if (typeof window !== 'undefined') return window.location.origin
23-
throw e
24-
}
19+
return getBrowserOrigin() ?? getBaseUrl()
2520
}
2621

2722
export const client = createAuthClient({

apps/sim/lib/auth/auth.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ import {
7979
isSignupEmailValidationEnabled,
8080
} from '@/lib/core/config/feature-flags'
8181
import { PlatformEvents } from '@/lib/core/telemetry'
82-
import { getBaseUrl } from '@/lib/core/utils/urls'
82+
import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls'
8383
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
8484
import { sendEmail } from '@/lib/messaging/email/mailer'
8585
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
@@ -145,6 +145,17 @@ const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS
145145
? new Set(env.BLOCKED_SIGNUP_DOMAINS.split(',').map((d) => d.trim().toLowerCase()))
146146
: null
147147

148+
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
149+
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
150+
)
151+
152+
if (env.NODE_ENV === 'production' && isLocalhostUrl(getBaseUrl())) {
153+
logger.warn(
154+
'NEXT_PUBLIC_APP_URL points to localhost in production. Self-hosted deployments must set NEXT_PUBLIC_APP_URL to the public URL users access (e.g. https://sim.example.com), otherwise auth POST requests from any non-localhost origin will be rejected by trustedOrigins. Set TRUSTED_ORIGINS to allow additional public origins.',
155+
{ baseUrl: getBaseUrl() }
156+
)
157+
}
158+
148159
const validStripeKey = env.STRIPE_SECRET_KEY
149160

150161
let stripeClient = null
@@ -159,6 +170,7 @@ export const auth = betterAuth({
159170
trustedOrigins: [
160171
getBaseUrl(),
161172
...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []),
173+
...additionalTrustedOrigins,
162174
'https://claude.ai',
163175
'https://claude.com',
164176
].filter(Boolean),

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const env = createEnv({
2525
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
2626
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
2727
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
28+
TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins.
2829
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
2930
SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains)
3031
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
@@ -400,7 +401,7 @@ export const env = createEnv({
400401
NEXT_PUBLIC_APP_URL: z.string().url(), // Base URL of the application (e.g., https://www.sim.ai)
401402

402403
// Client-side Services
403-
NEXT_PUBLIC_SOCKET_URL: z.string().url().optional(), // WebSocket server URL for real-time features
404+
NEXT_PUBLIC_SOCKET_URL: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()), // WebSocket server URL for real-time features (empty string treated as unset)
404405

405406
// Billing
406407
NEXT_PUBLIC_BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking (client-side)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetEnv } = vi.hoisted(() => ({
7+
mockGetEnv: vi.fn<(key: string) => string | undefined>(),
8+
}))
9+
10+
vi.mock('@/lib/core/config/env', () => ({
11+
env: {},
12+
getEnv: mockGetEnv,
13+
}))
14+
15+
vi.mock('@/lib/core/config/feature-flags', () => ({
16+
isProd: false,
17+
}))
18+
19+
import {
20+
getBrowserOrigin,
21+
getSocketUrl,
22+
isLocalhostUrl,
23+
parseOriginList,
24+
} from '@/lib/core/utils/urls'
25+
26+
function setLocation(url: string) {
27+
Object.defineProperty(window, 'location', {
28+
value: new URL(url),
29+
writable: true,
30+
configurable: true,
31+
})
32+
}
33+
34+
describe('getBrowserOrigin', () => {
35+
it('returns the page origin in the browser', () => {
36+
setLocation('https://example.com/some/path')
37+
expect(getBrowserOrigin()).toBe('https://example.com')
38+
})
39+
})
40+
41+
describe('getSocketUrl', () => {
42+
beforeEach(() => {
43+
mockGetEnv.mockReset()
44+
mockGetEnv.mockReturnValue(undefined)
45+
})
46+
47+
afterEach(() => {
48+
vi.restoreAllMocks()
49+
})
50+
51+
it('uses NEXT_PUBLIC_SOCKET_URL when explicitly set', () => {
52+
mockGetEnv.mockImplementation((key) =>
53+
key === 'NEXT_PUBLIC_SOCKET_URL' ? 'https://socket.example.com' : undefined
54+
)
55+
setLocation('https://app.example.com/')
56+
expect(getSocketUrl()).toBe('https://socket.example.com')
57+
})
58+
59+
it('returns the page origin when served from a non-localhost host', () => {
60+
setLocation('https://10.0.3.36/signup')
61+
expect(getSocketUrl()).toBe('https://10.0.3.36')
62+
})
63+
64+
it('falls back to localhost:3002 when served from localhost', () => {
65+
setLocation('http://localhost:3000/')
66+
expect(getSocketUrl()).toBe('http://localhost:3002')
67+
})
68+
69+
it('falls back to localhost:3002 when served from 127.0.0.1', () => {
70+
setLocation('http://127.0.0.1:3000/')
71+
expect(getSocketUrl()).toBe('http://localhost:3002')
72+
})
73+
74+
it('explicit env var wins over the localhost fallback', () => {
75+
mockGetEnv.mockImplementation((key) =>
76+
key === 'NEXT_PUBLIC_SOCKET_URL' ? 'http://realtime.local:3002' : undefined
77+
)
78+
setLocation('http://localhost:3000/')
79+
expect(getSocketUrl()).toBe('http://realtime.local:3002')
80+
})
81+
82+
it('treats whitespace-only env var as unset', () => {
83+
mockGetEnv.mockImplementation((key) => (key === 'NEXT_PUBLIC_SOCKET_URL' ? ' ' : undefined))
84+
setLocation('https://app.example.com/')
85+
expect(getSocketUrl()).toBe('https://app.example.com')
86+
})
87+
})
88+
89+
describe('parseOriginList', () => {
90+
it('returns an empty array for undefined, null, or empty input', () => {
91+
expect(parseOriginList(undefined)).toEqual([])
92+
expect(parseOriginList(null)).toEqual([])
93+
expect(parseOriginList('')).toEqual([])
94+
expect(parseOriginList(' ')).toEqual([])
95+
})
96+
97+
it('parses comma-separated origins and normalizes them', () => {
98+
expect(parseOriginList('https://a.example.com, https://b.example.com/path')).toEqual([
99+
'https://a.example.com',
100+
'https://b.example.com',
101+
])
102+
})
103+
104+
it('dedupes equal origins after normalization', () => {
105+
expect(
106+
parseOriginList('https://a.example.com,https://a.example.com/foo,https://a.example.com')
107+
).toEqual(['https://a.example.com'])
108+
})
109+
110+
it('drops invalid entries and reports them via the callback', () => {
111+
const invalid: string[] = []
112+
const result = parseOriginList('https://ok.example.com, not-a-url, ', (v) => invalid.push(v))
113+
expect(result).toEqual(['https://ok.example.com'])
114+
expect(invalid).toEqual(['not-a-url'])
115+
})
116+
117+
it('preserves non-default ports in the origin', () => {
118+
expect(parseOriginList('http://10.0.3.36:8080')).toEqual(['http://10.0.3.36:8080'])
119+
})
120+
})
121+
122+
describe('isLocalhostUrl', () => {
123+
it('matches localhost variants', () => {
124+
expect(isLocalhostUrl('http://localhost:3000')).toBe(true)
125+
expect(isLocalhostUrl('http://127.0.0.1')).toBe(true)
126+
expect(isLocalhostUrl('https://localhost')).toBe(true)
127+
})
128+
129+
it('does not match public hostnames or invalid URLs', () => {
130+
expect(isLocalhostUrl('https://10.0.3.36')).toBe(false)
131+
expect(isLocalhostUrl('https://app.example.com')).toBe(false)
132+
expect(isLocalhostUrl('not-a-url')).toBe(false)
133+
expect(isLocalhostUrl('')).toBe(false)
134+
})
135+
})

apps/sim/lib/core/utils/urls.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,62 @@ export function getEmailDomain(): string {
103103

104104
const DEFAULT_SOCKET_URL = 'http://localhost:3002'
105105
const DEFAULT_OLLAMA_URL = 'http://localhost:11434'
106+
const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
107+
108+
/**
109+
* Parses a comma-separated list of origins (e.g. from a `TRUSTED_ORIGINS` env
110+
* var) into a deduped array of normalized origins. Invalid entries are dropped.
111+
*
112+
* @param raw - Comma-separated origin list, or undefined/empty
113+
* @param onInvalid - Optional callback invoked once per invalid entry
114+
*/
115+
export function parseOriginList(
116+
raw: string | undefined | null,
117+
onInvalid?: (value: string) => void
118+
): string[] {
119+
if (!raw) return []
120+
const seen = new Set<string>()
121+
const origins: string[] = []
122+
for (const candidate of raw.split(',')) {
123+
const trimmed = candidate.trim()
124+
if (!trimmed) continue
125+
try {
126+
const { origin } = new URL(trimmed)
127+
if (!seen.has(origin)) {
128+
seen.add(origin)
129+
origins.push(origin)
130+
}
131+
} catch {
132+
onInvalid?.(trimmed)
133+
}
134+
}
135+
return origins
136+
}
137+
138+
/**
139+
* Returns true when the given URL points at a localhost loopback host.
140+
* Used to detect misconfigured deployments where `NEXT_PUBLIC_APP_URL` is left
141+
* at its development default in production.
142+
*/
143+
export function isLocalhostUrl(url: string): boolean {
144+
try {
145+
const { hostname } = new URL(url)
146+
return LOCALHOST_HOSTNAMES.has(hostname)
147+
} catch {
148+
return false
149+
}
150+
}
151+
152+
/**
153+
* Returns the current browser origin, or `null` when called server-side.
154+
*
155+
* Use this when an absolute URL is needed for a same-origin resource (auth API,
156+
* reverse-proxied socket, etc.) so a misconfigured `NEXT_PUBLIC_*` env var
157+
* baked into the client bundle at build time can't pin requests to the wrong host.
158+
*/
159+
export function getBrowserOrigin(): string | null {
160+
return typeof window !== 'undefined' ? window.location.origin : null
161+
}
106162

107163
/**
108164
* Returns the socket server URL for server-side internal API calls.
@@ -114,10 +170,25 @@ export function getSocketServerUrl(): string {
114170

115171
/**
116172
* Returns the socket server URL for client-side Socket.IO connections.
117-
* Reads from NEXT_PUBLIC_SOCKET_URL with a localhost fallback for development.
173+
*
174+
* Resolution order:
175+
* 1. `NEXT_PUBLIC_SOCKET_URL` if explicitly set (subdomain, separate host:port)
176+
* 2. In the browser when the page is served from a non-localhost origin, the
177+
* page's own origin — assumes the reverse proxy routes `/socket.io` to the
178+
* realtime service. This avoids shipping a hardcoded `localhost:3002` to
179+
* self-hosters behind nginx/Cloudflare.
180+
* 3. `http://localhost:3002` for local development and SSR.
118181
*/
119182
export function getSocketUrl(): string {
120-
return getEnv('NEXT_PUBLIC_SOCKET_URL') || DEFAULT_SOCKET_URL
183+
const explicit = getEnv('NEXT_PUBLIC_SOCKET_URL')?.trim()
184+
if (explicit) return explicit
185+
186+
const browserOrigin = getBrowserOrigin()
187+
if (browserOrigin && !LOCALHOST_HOSTNAMES.has(window.location.hostname)) {
188+
return browserOrigin
189+
}
190+
191+
return DEFAULT_SOCKET_URL
121192
}
122193

123194
/**

bun.lock

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docker-compose.prod.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ services:
1313
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio}
1414
- BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
1515
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
16+
# TRUSTED_ORIGINS: comma-separated public origins to trust for auth in
17+
# addition to NEXT_PUBLIC_APP_URL. Use when serving from multiple domains
18+
# (apex + www, alias hostnames, reverse-proxy IPs). Empty by default.
19+
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-}
1620
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
1721
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
1822
- API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-}
@@ -22,7 +26,11 @@ services:
2226
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL}
2327
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
2428
- SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002}
25-
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002}
29+
# NEXT_PUBLIC_SOCKET_URL is read by the browser. Leaving it unset lets the
30+
# client default to the page's own origin (assumes the reverse proxy routes
31+
# /socket.io). Set it explicitly only when the realtime service is on a
32+
# different host:port from the app (e.g. wss://socket.example.com).
33+
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-}
2634
- ADMISSION_GATE_MAX_INFLIGHT=${ADMISSION_GATE_MAX_INFLIGHT:-500}
2735
depends_on:
2836
db:

0 commit comments

Comments
 (0)