From 1071c23c842352ef12dabc788a9633fb5f505f71 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 09:51:56 -0400 Subject: [PATCH 1/7] feat(redirects): make allowed hosts configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The localhost-only allowlist exists so a reachable emulator cannot be turned into an open redirect, but it also blocks test environments that fake production-like hostnames to stay close to production — a reasonable setup that had no way to opt in. Configured hosts add to the localhost set rather than replacing it, so the guard stays on by default and existing callbacks keep working. Entries are normalized at startup, meaning a malformed host fails loudly instead of silently never matching a request. --- README.md | 44 +++++ src/cli.ts | 24 +++ src/index.ts | 18 +++ src/workos/constants.ts | 1 + src/workos/helpers.ts | 84 ++++++++-- src/workos/redirect-hosts.spec.ts | 215 +++++++++++++++++++++++++ src/workos/routes/auth.ts | 4 +- src/workos/routes/data-integrations.ts | 4 +- src/workos/routes/sessions.ts | 4 +- src/workos/routes/sso.ts | 10 +- 10 files changed, 386 insertions(+), 22 deletions(-) create mode 100644 src/workos/redirect-hosts.spec.ts diff --git a/README.md b/README.md index 2d84a00..4205800 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ workos-emulate --port 9100 --json workos-emulate --seed workos-emulate.config.yaml workos-emulate --interactive # serve login pages for E2E browser testing workos-emulate --signing-key ci-key.pem --issuer https://api.workos.com # stable JWKS and iss +workos-emulate --redirect-hosts app.example.test # allow a non-localhost redirect_uri workos-emulate --version ``` @@ -679,6 +680,48 @@ is stable for a pinned key without being pinned separately. > A pinned signing key is a test fixture, not a secret to reuse anywhere real. Never point the > emulator at a key your production environment trusts. +## Redirect URI Hosts + +The authorize endpoints refuse to redirect anywhere but `localhost`, `127.0.0.1` and `[::1]`, so a +reachable emulator cannot be turned into an open redirect. If your test environment fakes +production-like hostnames, list them: + +```bash +workos-emulate --redirect-hosts app.example.test,auth.example.test + +# Repeatable, and each occurrence may be a comma-separated list +workos-emulate --redirect-hosts app.example.test --redirect-hosts auth.example.test + +# Any subdomain of example.test (the apex itself is not matched) +workos-emulate --redirect-hosts '*.example.test' + +# Any host at all — the check is off +workos-emulate --redirect-hosts '*' +``` + +`WORKOS_EMULATE_REDIRECT_HOSTS=app.example.test,*.internal.test` is the environment equivalent, for +a compose file. The flag wins over the environment. + +Programmatically: + +```ts +const emulator = await createEmulator({ + allowedRedirectHosts: ['app.example.test', '*.internal.test'], +}); +``` + +Notes: + +- Configured hosts **add to** the localhost set rather than replacing it, so existing callbacks keep + working. +- An entry is a hostname (`app.example.test`), a subdomain wildcard (`*.example.test`), or `*`. A + whole origin (`https://app.example.test:8443`) is accepted and reduced to its hostname — ports and + schemes are never part of the check. +- The check applies to `redirect_uri` on `/user_management/authorize`, `/sso/authorize` and + `/data-integrations/:slug/authorize`, and to `return_to` on `/user_management/sessions/logout`. +- A host that could never match (`https://`, anything with whitespace) fails at startup rather than + silently rejecting every request. + ## Error Hooks Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.). @@ -997,6 +1040,7 @@ The WorkOS Emulator is designed for testing and development environments. When u ### Network Security - **Bind to localhost**: By default, the emulator binds to `localhost`, so its unauthenticated endpoints are only reachable from the local machine. To intentionally expose it to other hosts, pass `--host 0.0.0.0` (CLI) or `hostname: '0.0.0.0'` (`createEmulator`), and protect it with a firewall or VPN. +- **Open redirect protection**: The authorize endpoints only redirect to localhost by default. `--redirect-hosts` (or `allowedRedirectHosts`) widens that for test environments with production-like hostnames; `--redirect-hosts '*'` disables the check entirely, so only use it on an emulator nothing untrusted can reach. See [Redirect URI Hosts](#redirect-uri-hosts). - **No CORS restrictions**: The emulator doesn't enforce CORS. Configure CORS in your application if needed. - **No TLS/SSL**: The emulator doesn't provide HTTPS. Use a reverse proxy (nginx, Caddy) for TLS termination in production. diff --git a/src/cli.ts b/src/cli.ts index e43d35e..5c3d87a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ interface CliArgs { signingKey?: string; kid?: string; issuer?: string; + redirectHosts?: string[]; json: boolean; help: boolean; version: boolean; @@ -48,6 +49,11 @@ Options: every restart. Pin it to keep the JWKS stable. --kid Key id to advertise in the JWKS (default: derived from the key) --issuer Value to mint as the "iss" claim (default: the emulator's own URL) + --redirect-hosts + Comma-separated hosts a redirect_uri may point at, on top of localhost + (always allowed). Use for test environments with production-like + hostnames. Accepts subdomain wildcards ("*.example.test") and "*" to + allow any host. Repeatable. --interactive, -i Show login pages for SSO/AuthKit (for E2E browser testing) --validate-config Validate seed config file without starting server --json Print startup details as JSON @@ -58,6 +64,7 @@ Environment: WORKOS_EMULATE_SIGNING_KEY= Same as --signing-key WORKOS_EMULATE_KID= Same as --kid WORKOS_EMULATE_ISSUER= Same as --issuer + WORKOS_EMULATE_REDIRECT_HOSTS= Same as --redirect-hosts NO_UPDATE_NOTIFIER=1 Disable update checks WORKOS_EMULATE_DISABLE_UPDATE_CHECK=1 Disable update checks `); @@ -129,6 +136,14 @@ function parseArgs(argv: string[]): CliArgs { continue; } + if (arg === '--redirect-hosts' || arg.startsWith('--redirect-hosts=')) { + const value = arg === '--redirect-hosts' ? argv[++i] : arg.slice('--redirect-hosts='.length); + if (!value) throw new Error('--redirect-hosts requires a value'); + // Repeatable, and each occurrence may itself be a comma-separated list. + parsed.redirectHosts = [...(parsed.redirectHosts ?? []), ...splitHosts(value)]; + continue; + } + if (arg === '--seed' || arg === '-s') { const value = argv[++i]; if (!value) throw new Error(`${arg} requires a value`); @@ -156,6 +171,13 @@ function parseArgs(argv: string[]): CliArgs { return parsed; } +function splitHosts(value: string): string[] { + return value + .split(',') + .map((host) => host.trim()) + .filter((host) => host !== ''); +} + function parsePort(value: string): number { const port = Number(value); if (!Number.isInteger(port) || port < 0 || port > 65535) { @@ -242,6 +264,7 @@ async function main(): Promise { const signingKeyPath = argv.signingKey ?? process.env.WORKOS_EMULATE_SIGNING_KEY; const kid = argv.kid ?? process.env.WORKOS_EMULATE_KID; const issuer = argv.issuer ?? process.env.WORKOS_EMULATE_ISSUER; + const allowedRedirectHosts = argv.redirectHosts ?? splitHosts(process.env.WORKOS_EMULATE_REDIRECT_HOSTS ?? ''); const emulator = await createEmulator({ port: argv.port, @@ -249,6 +272,7 @@ async function main(): Promise { seed: seedConfig, issuer, signingKey: signingKeyPath || kid ? { privateKey: readSigningKey(signingKeyPath), kid } : undefined, + allowedRedirectHosts, interactiveAuth: argv.interactive, }); diff --git a/src/index.ts b/src/index.ts index 81b6a2d..d9c63c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { } from './core/index.js'; import { workosPlugin, seedFromConfig, type WorkOSSeedConfig } from './workos/index.js'; import { STORE_KEYS } from './workos/constants.js'; +import { normalizeRedirectHosts } from './workos/helpers.js'; import { serve } from '@hono/node-server'; import { parseJsonBody } from './core/index.js'; @@ -64,6 +65,14 @@ export interface EmulatorOptions { * or to pre-sign tokens offline with the same key the emulator verifies. */ signingKey?: SigningKeyOptions; + /** + * Extra hosts a `redirect_uri` (or a session logout `return_to`) may point at. The emulator + * refuses to redirect anywhere else so it cannot be used as an open redirect; `localhost`, + * `127.0.0.1` and `[::1]` are always allowed. Add the production-like hostnames your test + * environment fakes — `['app.example.test']` — or a subdomain wildcard + * (`['*.example.test']`). `['*']` allows any host, which turns the check off entirely. + */ + allowedRedirectHosts?: string[]; interactiveAuth?: boolean; webhookRetryConfig?: { maxRetries?: number; @@ -119,6 +128,14 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise { + if (allowedRedirectHosts.length > 0) store.setData(STORE_KEYS.allowedRedirectHosts, allowedRedirectHosts); + }; + applyRedirectHosts(); + if (options.webhookRetryConfig) { store.setData('webhookRetryConfig', options.webhookRetryConfig); } @@ -225,6 +242,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise value.trim() !== '').map(normalizeRedirectHost); +} + +function hostMatches(hostname: string, pattern: string): boolean { + if (pattern === ANY_HOST) return true; + // `*.example.test` covers subdomains only, matching how redirect allow-lists usually read. + if (pattern.startsWith('*.')) return hostname.endsWith(pattern.slice(1)); + return hostname === pattern; +} /** - * Validate that a redirect_uri points to a localhost origin. - * Prevents the emulator from being used as an open redirect. + * Validate that a redirect_uri points to a host the emulator is willing to redirect to. + * Prevents the emulator from being used as an open redirect. Localhost is always allowed; + * `allowedRedirectHosts` (`--redirect-hosts`) adds to that for test environments that use + * production-like hostnames. */ -export function assertLocalRedirectUri(uri: string): void { +export function assertAllowedRedirectUri(uri: string, store: Store): void { let parsed: URL; try { parsed = new URL(uri); } catch { throw new WorkOSApiError(400, 'Invalid redirect_uri', 'invalid_redirect_uri'); } - if (!ALLOWED_REDIRECT_HOSTS.has(parsed.hostname)) { - throw new WorkOSApiError( - 400, - `redirect_uri must point to localhost, got ${parsed.hostname}`, - 'invalid_redirect_uri', - ); - } + + const configured = store.getData(STORE_KEYS.allowedRedirectHosts) ?? []; + const allowed = [...DEFAULT_ALLOWED_REDIRECT_HOSTS, ...configured]; + const hostname = parsed.hostname.toLowerCase(); + if (allowed.some((pattern) => hostMatches(hostname, pattern))) return; + + throw new WorkOSApiError( + 400, + configured.length > 0 + ? `redirect_uri host ${parsed.hostname} is not allowed; allowed hosts: ${allowed.join(', ')}` + : `redirect_uri must point to localhost, got ${parsed.hostname}. Pass --redirect-hosts to allow other hosts.`, + 'invalid_redirect_uri', + ); } const AUTH_CHALLENGE_EXCLUDE = new Set([...INTERNAL_FIELDS, 'code']); diff --git a/src/workos/redirect-hosts.spec.ts b/src/workos/redirect-hosts.spec.ts new file mode 100644 index 0000000..dabc0d6 --- /dev/null +++ b/src/workos/redirect-hosts.spec.ts @@ -0,0 +1,215 @@ +/** + * Configurable redirect hosts. + * + * The authorize endpoints refuse to redirect anywhere but localhost so the emulator cannot be + * used as an open redirect. Test environments that fake production-like hostnames need to widen + * that, so extra hosts are configurable — without the default ever becoming "anything goes". + */ +import { describe, it, expect } from 'bun:test'; +import { createServer, type ApiKeyMap } from '../core/index.js'; +import { createEmulator } from '../index.js'; +import { workosPlugin } from './index.js'; +import { STORE_KEYS } from './constants.js'; +import { normalizeRedirectHost, normalizeRedirectHosts } from './helpers.js'; + +const apiKeys: ApiKeyMap = { sk_test_redirect: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_redirect', 'Content-Type': 'application/json' }; + +function createTestApp(allowedRedirectHosts?: string[]) { + const { app, store } = createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); + if (allowedRedirectHosts) store.setData(STORE_KEYS.allowedRedirectHosts, allowedRedirectHosts); + return { app, store }; +} + +const json = (res: Response) => res.json() as Promise; + +describe('normalizeRedirectHost', () => { + it('keeps a bare hostname, lowercased', () => { + expect(normalizeRedirectHost('App.Example.Test')).toBe('app.example.test'); + }); + + it('reduces a whole origin to its hostname', () => { + expect(normalizeRedirectHost('https://app.example.test:8443/callback')).toBe('app.example.test'); + }); + + it('strips a bare host:port', () => { + expect(normalizeRedirectHost('app.example.test:3000')).toBe('app.example.test'); + }); + + it('brackets bare IPv6 and leaves bracketed IPv6 alone', () => { + expect(normalizeRedirectHost('::1')).toBe('[::1]'); + expect(normalizeRedirectHost('[fd00::1]:3000')).toBe('[fd00::1]'); + }); + + it('passes wildcards through', () => { + expect(normalizeRedirectHost('*')).toBe('*'); + expect(normalizeRedirectHost('*.example.test')).toBe('*.example.test'); + }); + + it('rejects input that could never match', () => { + expect(() => normalizeRedirectHost('https://')).toThrow('Invalid redirect host'); + expect(() => normalizeRedirectHost('two hosts')).toThrow('Invalid redirect host'); + }); + + it('drops blank entries from a list', () => { + expect(normalizeRedirectHosts(['app.example.test', ' ', ''])).toEqual(['app.example.test']); + }); +}); + +describe('redirect host validation (default: localhost only)', () => { + const { app } = createTestApp(); + + it('rejects a non-localhost AuthKit redirect_uri', async () => { + const res = await app.request('/user_management/authorize?redirect_uri=https://app.example.test/callback'); + expect(res.status).toBe(400); + const body = await json(res); + expect(body.code).toBe('invalid_redirect_uri'); + expect(body.message).toContain('must point to localhost'); + }); + + it('rejects a non-localhost SSO redirect_uri', async () => { + const res = await app.request('/sso/authorize?connection=conn_missing&redirect_uri=https://app.example.test/cb'); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invalid_redirect_uri'); + }); + + it('rejects a non-localhost data integration redirect_uri', async () => { + const res = await app.request('/data-integrations/salesforce/authorize?redirect_uri=https://app.example.test/cb'); + expect(res.status).toBe(400); + }); + + it('rejects a non-localhost logout return_to', async () => { + const res = await app.request( + '/user_management/sessions/logout?session_id=session_x&return_to=https://app.example.test/bye', + ); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invalid_redirect_uri'); + }); + + it('still allows the localhost family', async () => { + for (const uri of ['http://localhost:3000/cb', 'http://127.0.0.1:3000/cb', 'http://[::1]:3000/cb']) { + const res = await app.request(`/data-integrations/salesforce/authorize?redirect_uri=${encodeURIComponent(uri)}`, { + redirect: 'manual', + }); + expect(res.status).toBe(302); + } + }); +}); + +describe('redirect host validation (configured hosts)', () => { + it('accepts a configured host on the AuthKit authorize endpoint', async () => { + const { app } = createTestApp(['app.example.test']); + await app.request('/user_management/users', { + method: 'POST', + headers, + body: JSON.stringify({ email: 'redirect@test.com' }), + }); + + const res = await app.request('/user_management/authorize?redirect_uri=https://app.example.test/callback', { + redirect: 'manual', + }); + expect(res.status).toBe(302); + const location = new URL(res.headers.get('Location')!); + expect(location.host).toBe('app.example.test'); + expect(location.searchParams.get('code')).toBeTruthy(); + }); + + it('accepts a configured host on the logout and data integration endpoints', async () => { + const { app } = createTestApp(['app.example.test']); + + const logout = await app.request( + '/user_management/sessions/logout?session_id=session_x&return_to=https://app.example.test/bye', + { redirect: 'manual' }, + ); + expect(logout.status).toBe(302); + + const integration = await app.request( + '/data-integrations/salesforce/authorize?redirect_uri=https://app.example.test/cb', + { redirect: 'manual' }, + ); + expect(integration.status).toBe(302); + }); + + it('gets past the redirect check on the SSO endpoint', async () => { + const { app } = createTestApp(['app.example.test']); + const res = await app.request('/sso/authorize?connection=conn_missing&redirect_uri=https://app.example.test/cb'); + // The host is accepted, so the request fails on the missing connection instead. + expect(res.status).toBe(404); + expect((await json(res)).code).toBe('connection_not_found'); + }); + + it('still rejects hosts that were not configured', async () => { + const { app } = createTestApp(['app.example.test']); + const res = await app.request('/user_management/authorize?redirect_uri=https://evil.example.com/callback'); + expect(res.status).toBe(400); + const body = await json(res); + expect(body.code).toBe('invalid_redirect_uri'); + expect(body.message).toContain('app.example.test'); + }); + + it('matches subdomains of a wildcard, but not its apex', async () => { + const { app } = createTestApp(['*.example.test']); + + const sub = await app.request('/data-integrations/salesforce/authorize?redirect_uri=https://app.example.test/cb', { + redirect: 'manual', + }); + expect(sub.status).toBe(302); + + const apex = await app.request('/data-integrations/salesforce/authorize?redirect_uri=https://example.test/cb', { + redirect: 'manual', + }); + expect(apex.status).toBe(400); + }); + + it('accepts any host when configured with *', async () => { + const { app } = createTestApp(['*']); + const res = await app.request('/data-integrations/salesforce/authorize?redirect_uri=https://anything.invalid/cb', { + redirect: 'manual', + }); + expect(res.status).toBe(302); + }); + + it('rejects a malformed redirect_uri regardless of configuration', async () => { + const { app } = createTestApp(['*']); + const res = await app.request('/data-integrations/salesforce/authorize?redirect_uri=not-a-url'); + expect(res.status).toBe(400); + expect((await json(res)).message).toBe('Invalid redirect_uri'); + }); +}); + +describe('createEmulator({ allowedRedirectHosts })', () => { + it('applies the configured hosts, normalizing origins, and survives reset()', async () => { + const emulator = await createEmulator({ port: 0, allowedRedirectHosts: ['https://app.example.test:8443'] }); + try { + const authorize = () => + fetch(`${emulator.url}/data-integrations/salesforce/authorize?redirect_uri=https://app.example.test/cb`, { + redirect: 'manual', + }); + + expect((await authorize()).status).toBe(302); + emulator.reset(); + expect((await authorize()).status).toBe(302); + } finally { + await emulator.close(); + } + }); + + it('fails at startup on a host that could never match', async () => { + await expect(createEmulator({ port: 0, allowedRedirectHosts: ['https://'] })).rejects.toThrow( + 'Invalid redirect host', + ); + }); + + it('leaves the localhost-only default in place when unset', async () => { + const emulator = await createEmulator({ port: 0 }); + try { + const res = await fetch( + `${emulator.url}/data-integrations/salesforce/authorize?redirect_uri=https://app.example.test/cb`, + { redirect: 'manual' }, + ); + expect(res.status).toBe(400); + } finally { + await emulator.close(); + } + }); +}); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 985f6e4..a5f11f5 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -15,7 +15,7 @@ import { verifyPassword, isExpired, expiresIn, - assertLocalRedirectUri, + assertAllowedRedirectUri, sealSession, AUTH_METHOD_SESSION_VALUES, resolveResponseAuthMethod, @@ -67,7 +67,7 @@ export function authRoutes(ctx: RouteContext): void { function resolveAndRedirect(c: any, params: AuthorizeParams) { const { redirectUri, state, codeChallenge, codeChallengeMethod, loginHint, clientId } = params; - assertLocalRedirectUri(redirectUri); + assertAllowedRedirectUri(redirectUri, store); let user; if (loginHint) { diff --git a/src/workos/routes/data-integrations.ts b/src/workos/routes/data-integrations.ts index d10d735..c31a063 100644 --- a/src/workos/routes/data-integrations.ts +++ b/src/workos/routes/data-integrations.ts @@ -1,6 +1,6 @@ import { type RouteContext, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { assertLocalRedirectUri, generateVerificationToken, expiresIn, isExpired } from '../helpers.js'; +import { assertAllowedRedirectUri, generateVerificationToken, expiresIn, isExpired } from '../helpers.js'; export function dataIntegrationRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -16,7 +16,7 @@ export function dataIntegrationRoutes(ctx: RouteContext): void { if (!redirectUri) { throw new WorkOSApiError(400, 'redirect_uri is required', 'invalid_request'); } - assertLocalRedirectUri(redirectUri); + assertAllowedRedirectUri(redirectUri, store); const code = generateVerificationToken(); ws.dataIntegrationAuths.insert({ diff --git a/src/workos/routes/sessions.ts b/src/workos/routes/sessions.ts index cf571f5..ee98fa2 100644 --- a/src/workos/routes/sessions.ts +++ b/src/workos/routes/sessions.ts @@ -1,6 +1,6 @@ import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatSession, assertLocalRedirectUri } from '../helpers.js'; +import { formatSession, assertAllowedRedirectUri } from '../helpers.js'; export function sessionRoutes(ctx: RouteContext): void { const { app, store, jwt } = ctx; @@ -51,7 +51,7 @@ export function sessionRoutes(ctx: RouteContext): void { } if (returnTo) { - assertLocalRedirectUri(returnTo); + assertAllowedRedirectUri(returnTo, store); return c.redirect(returnTo); } return c.json({ success: true }); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index f1b6e43..9460e41 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -1,7 +1,13 @@ import type { Context } from 'hono'; import { type RouteContext, parseJsonBody, WorkOSApiError, generateId } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatSSOProfile, expiresIn, isExpired, assertLocalRedirectUri, emitAuthenticationEvent } from '../helpers.js'; +import { + formatSSOProfile, + expiresIn, + isExpired, + assertAllowedRedirectUri, + emitAuthenticationEvent, +} from '../helpers.js'; import type { WorkOSConnection } from '../entities.js'; import type { EventBus } from '../event-bus.js'; import { STORE_KEY_PREFIXES, STORE_KEYS } from '../constants.js'; @@ -23,7 +29,7 @@ export function ssoRoutes(ctx: RouteContext): void { function resolveAndRedirect(c: any, params: SSOAuthorizeParams) { const { redirectUri, state, connectionId, organizationId, domainHint, email: loginHint } = params; - assertLocalRedirectUri(redirectUri); + assertAllowedRedirectUri(redirectUri, store); let connection: WorkOSConnection | undefined; From 58f2ac43be983ab9d42ac62252c77378128026ad Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 10:24:18 -0400 Subject: [PATCH 2/7] fix(redirects): reject host patterns that can never match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emptiness-and-whitespace check was too weak to honor what the function documents. `*example.test` (a wildcard missing its dot), `*.` and `app.example.test/path` all normalized cleanly, started the emulator, and then matched nothing — leaving a 400 on the redirect the entry was added to allow, which is the precise failure startup validation is meant to turn into a loud one. Validating the shape also closes the bogus-IPv6 path: `host:notaport` reached the bracketing branch only because its port was non-numeric, and came out as `[host:notaport]`. --- src/workos/helpers.ts | 25 +++++++++++++++++++++++- src/workos/redirect-hosts.spec.ts | 32 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index a2e5026..2b616e4 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -466,12 +466,35 @@ export function normalizeRedirectHost(value: string): string { host = /^\d+$/.test(port) && !host.slice(0, portIndex).includes(':') ? host.slice(0, portIndex) : `[${host}]`; } - if (!host || /\s/.test(host)) { + if (!isMatchableHostPattern(host)) { throw new Error(`Invalid redirect host: ${JSON.stringify(value)}`); } return host; } +/** A DNS label: alphanumeric, inner hyphens allowed, dot-separated. */ +const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; + +/** + * Whether a normalized pattern can ever match a `URL.hostname`. Checking only for emptiness and + * whitespace let `*example.test` (wildcard without the dot), `*.` and `app.example.test/path` + * through — each accepted at startup and then silently matching nothing, which is exactly the + * failure this validation exists to prevent. + */ +function isMatchableHostPattern(host: string): boolean { + if (host === ANY_HOST) return true; + const bare = host.startsWith('*.') ? host.slice(2) : host; + if (!bare) return false; + if (bare.startsWith('[')) { + if (!bare.endsWith(']')) return false; + const inner = bare.slice(1, -1); + // Hex groups, IPv4-mapped tails, and the `::` elision — but not an arbitrary `host:port` + // that only reached this branch because its port was not numeric. + return inner.includes(':') && /^[0-9a-f:.]+$/.test(inner); + } + return HOSTNAME.test(bare); +} + /** Normalize a list of configured redirect hosts, dropping blank entries. */ export function normalizeRedirectHosts(values: readonly string[]): string[] { return values.filter((value) => value.trim() !== '').map(normalizeRedirectHost); diff --git a/src/workos/redirect-hosts.spec.ts b/src/workos/redirect-hosts.spec.ts index dabc0d6..043ff6c 100644 --- a/src/workos/redirect-hosts.spec.ts +++ b/src/workos/redirect-hosts.spec.ts @@ -51,6 +51,38 @@ describe('normalizeRedirectHost', () => { expect(() => normalizeRedirectHost('two hosts')).toThrow('Invalid redirect host'); }); + it('rejects patterns that look plausible but can never match a hostname', () => { + for (const bad of [ + '*example.test', // wildcard without the separating dot + '*.', // wildcard with nothing to anchor to + 'app.example.test/path', // a path is not part of a hostname + 'app.example.test:notaport', // would otherwise be bracketed as bogus IPv6 + '-leading.example.test', + 'trailing-.example.test', + 'double..dot.test', + '*.*.example.test', + ]) { + expect(() => normalizeRedirectHost(bad)).toThrow('Invalid redirect host'); + } + }); + + it('still accepts the forms it documents', () => { + for (const good of [ + 'localhost', + '127.0.0.1', + '[::1]', + '[fd00::1]:3000', + 'app.example.test', + 'app.example.test:3000', + 'https://app.example.test:8443/callback', + '*.example.test', + 'xn--80ak6aa92e.test', // punycode + '*', + ]) { + expect(() => normalizeRedirectHost(good)).not.toThrow(); + } + }); + it('drops blank entries from a list', () => { expect(normalizeRedirectHosts(['app.example.test', ' ', ''])).toEqual(['app.example.test']); }); From 95353c4a06d5e4fbfee5e3afa80b9d72e2fb6149 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 11:38:51 -0400 Subject: [PATCH 3/7] fix(redirects): close the gaps the host check left open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from review, all cases where the guard's shape did not match what it claims to enforce. A bare internationalized host could not be spelled in a way that matched. `URL.hostname` punycodes, so a request for møller.test arrives as xn--mller-vua.test; the origin form worked only because URL converted it on the way through, leaving the documented bare form with no correct spelling and a startup error that said nothing about why. The scheme was never looked at. `javascript://localhost/%0aalert(1)` parses with a hostname of localhost, so a script URI passed the localhost check on the strength of an authority it never navigates to — the precise thing the check exists to refuse. Refused now regardless of configuration, since `*` widens which host may be redirected to, not what a redirect may execute. Custom app schemes stay allowed: a native client's myapp://callback is a real redirect target and carries no script. IPv6 patterns were validated by character class, so `[:::]` and `[....]` were accepted and then matched nothing — the same silent no-match that startup validation was added to turn into a loud failure. Also covers the two entry points nothing exercised: the flag's repeated, comma-separated and inline forms, the environment variable a compose file sets, and the flag winning over it. --- README.md | 9 +- src/workos/helpers.ts | 63 ++++++++++--- src/workos/redirect-hosts.spec.ts | 149 ++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4205800..26a9826 100644 --- a/README.md +++ b/README.md @@ -716,11 +716,16 @@ Notes: working. - An entry is a hostname (`app.example.test`), a subdomain wildcard (`*.example.test`), or `*`. A whole origin (`https://app.example.test:8443`) is accepted and reduced to its hostname — ports and - schemes are never part of the check. + schemes are never part of the host check. - The check applies to `redirect_uri` on `/user_management/authorize`, `/sso/authorize` and `/data-integrations/:slug/authorize`, and to `return_to` on `/user_management/sessions/logout`. +- An internationalized hostname may be written either way: `møller.test` and its punycode + (`xn--mller-vua.test`) normalize to the same entry, since that is the form a request carries. - A host that could never match (`https://`, anything with whitespace) fails at startup rather than silently rejecting every request. +- `javascript:`, `data:`, `vbscript:`, `blob:` and `file:` redirect URIs are always refused, `*` + included — `javascript://localhost/…` parses with an allowed hostname it never navigates to. + Custom app schemes (`myapp://callback`, for native clients) are allowed if their host is. ## Error Hooks @@ -1040,7 +1045,7 @@ The WorkOS Emulator is designed for testing and development environments. When u ### Network Security - **Bind to localhost**: By default, the emulator binds to `localhost`, so its unauthenticated endpoints are only reachable from the local machine. To intentionally expose it to other hosts, pass `--host 0.0.0.0` (CLI) or `hostname: '0.0.0.0'` (`createEmulator`), and protect it with a firewall or VPN. -- **Open redirect protection**: The authorize endpoints only redirect to localhost by default. `--redirect-hosts` (or `allowedRedirectHosts`) widens that for test environments with production-like hostnames; `--redirect-hosts '*'` disables the check entirely, so only use it on an emulator nothing untrusted can reach. See [Redirect URI Hosts](#redirect-uri-hosts). +- **Open redirect protection**: The authorize endpoints only redirect to localhost by default. `--redirect-hosts` (or `allowedRedirectHosts`) widens that for test environments with production-like hostnames; `--redirect-hosts '*'` disables the host check entirely, so only use it on an emulator nothing untrusted can reach. Script-bearing schemes (`javascript:`, `data:`) are refused regardless. See [Redirect URI Hosts](#redirect-uri-hosts). - **No CORS restrictions**: The emulator doesn't enforce CORS. Configure CORS in your application if needed. - **No TLS/SSL**: The emulator doesn't provide HTTPS. Use a reverse proxy (nginx, Caddy) for TLS termination in production. diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 2b616e4..0ba063b 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -1,4 +1,6 @@ import { randomBytes, createHash, createCipheriv } from 'node:crypto'; +import { isIPv6 } from 'node:net'; +import { domainToASCII } from 'node:url'; import { WorkOSApiError, generateId, type CursorPaginatedResult, type Entity, type Store } from '../core/index.js'; import { EVENTS, STORE_KEYS, type AuthenticationEventData, type WorkOSEventName } from './constants.js'; import type { WorkOSStore } from './store.js'; @@ -441,8 +443,8 @@ const ANY_HOST = '*'; /** * Normalize a configured redirect host into the form `URL.hostname` produces: lowercase, - * no scheme, no port, IPv6 in brackets. Accepts a bare hostname (`app.example.test`), a - * subdomain wildcard (`*.example.test`), `*`, or a whole origin + * punycode, no scheme, no port, IPv6 in brackets. Accepts a bare hostname + * (`app.example.test`), a subdomain wildcard (`*.example.test`), `*`, or a whole origin * (`https://app.example.test:8443`), since an origin is what people usually have on hand. * Throws on input that would silently never match. */ @@ -459,11 +461,14 @@ export function normalizeRedirectHost(value: string): string { } else if (host.startsWith('[')) { // Bracketed IPv6, possibly with a port: keep everything through the closing bracket. host = host.slice(0, host.indexOf(']') + 1); - } else if (host.includes(':')) { - const portIndex = host.lastIndexOf(':'); - const port = host.slice(portIndex + 1); - // `example.test:3000` is a host and port; anything else with colons is bare IPv6. - host = /^\d+$/.test(port) && !host.slice(0, portIndex).includes(':') ? host.slice(0, portIndex) : `[${host}]`; + } else { + if (host.includes(':')) { + const portIndex = host.lastIndexOf(':'); + const port = host.slice(portIndex + 1); + // `example.test:3000` is a host and port; anything else with colons is bare IPv6. + host = /^\d+$/.test(port) && !host.slice(0, portIndex).includes(':') ? host.slice(0, portIndex) : `[${host}]`; + } + if (!host.startsWith('[')) host = toAsciiHost(host); } if (!isMatchableHostPattern(host)) { @@ -472,6 +477,23 @@ export function normalizeRedirectHost(value: string): string { return host; } +/** + * Convert an internationalized hostname to the punycode `URL.hostname` yields, so `møller.test` + * can be configured the way its owner spells it. Without this, only the origin form + * (`https://møller.test`) worked — `URL` punycodes on the way through — leaving the bare form + * with no spelling that could ever match. Returns '' for input that is not a domain at all, + * which the shape check then rejects. + */ +function toAsciiHost(host: string): string { + // Everything a hostname may legally contain is printable ASCII; anything else needs IDNA. + if (!/[^ -~]/.test(host)) return host; + const wildcard = host.startsWith('*.'); + // `*` is not an IDNA label, so convert only what follows it. + const ascii = domainToASCII(wildcard ? host.slice(2) : host); + if (!ascii) return ''; + return wildcard ? `*.${ascii}` : ascii; +} + /** A DNS label: alphanumeric, inner hyphens allowed, dot-separated. */ const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; @@ -486,11 +508,9 @@ function isMatchableHostPattern(host: string): boolean { const bare = host.startsWith('*.') ? host.slice(2) : host; if (!bare) return false; if (bare.startsWith('[')) { - if (!bare.endsWith(']')) return false; - const inner = bare.slice(1, -1); - // Hex groups, IPv4-mapped tails, and the `::` elision — but not an arbitrary `host:port` - // that only reached this branch because its port was not numeric. - return inner.includes(':') && /^[0-9a-f:.]+$/.test(inner); + // A real address, not just IPv6-shaped characters: `[:::]` and `[....]` would pass a + // character-class check and then match no hostname, the same silent failure as above. + return bare.endsWith(']') && isIPv6(bare.slice(1, -1)); } return HOSTNAME.test(bare); } @@ -500,6 +520,15 @@ export function normalizeRedirectHosts(values: readonly string[]): string[] { return values.filter((value) => value.trim() !== '').map(normalizeRedirectHost); } +/** + * Schemes that run in the page instead of navigating to it. The host check alone does not stop + * them: `javascript://localhost/%0aalert(1)` parses with a hostname of `localhost`, so a script + * URI reaches the redirect on the strength of an authority it never uses. Custom app schemes + * (`myapp://callback`, RFC 8252) stay allowed — they are a real redirect target a native client + * tests against, and they carry no script. + */ +const SCRIPT_REDIRECT_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'blob:', 'file:']); + function hostMatches(hostname: string, pattern: string): boolean { if (pattern === ANY_HOST) return true; // `*.example.test` covers subdomains only, matching how redirect allow-lists usually read. @@ -521,6 +550,16 @@ export function assertAllowedRedirectUri(uri: string, store: Store): void { throw new WorkOSApiError(400, 'Invalid redirect_uri', 'invalid_redirect_uri'); } + // Checked before the host, and regardless of configuration: `*` widens which host may be + // redirected to, never what a redirect is allowed to execute. + if (SCRIPT_REDIRECT_SCHEMES.has(parsed.protocol)) { + throw new WorkOSApiError( + 400, + `redirect_uri scheme ${parsed.protocol.slice(0, -1)} is not allowed`, + 'invalid_redirect_uri', + ); + } + const configured = store.getData(STORE_KEYS.allowedRedirectHosts) ?? []; const allowed = [...DEFAULT_ALLOWED_REDIRECT_HOSTS, ...configured]; const hostname = parsed.hostname.toLowerCase(); diff --git a/src/workos/redirect-hosts.spec.ts b/src/workos/redirect-hosts.spec.ts index 043ff6c..080adce 100644 --- a/src/workos/redirect-hosts.spec.ts +++ b/src/workos/redirect-hosts.spec.ts @@ -51,6 +51,16 @@ describe('normalizeRedirectHost', () => { expect(() => normalizeRedirectHost('two hosts')).toThrow('Invalid redirect host'); }); + // A request carries the punycode `URL.hostname` produced, so a configured entry has to reach + // the same form or the bare spelling of an internationalized host could never match. + it('punycodes an internationalized hostname, bare or wildcarded', () => { + expect(normalizeRedirectHost('møller.test')).toBe('xn--mller-vua.test'); + expect(normalizeRedirectHost('MØLLER.test')).toBe('xn--mller-vua.test'); + expect(normalizeRedirectHost('*.møller.test')).toBe('*.xn--mller-vua.test'); + // Which is what the origin form already yielded, since URL punycodes on the way through. + expect(normalizeRedirectHost('https://møller.test')).toBe('xn--mller-vua.test'); + }); + it('rejects patterns that look plausible but can never match a hostname', () => { for (const bad of [ '*example.test', // wildcard without the separating dot @@ -61,6 +71,10 @@ describe('normalizeRedirectHost', () => { 'trailing-.example.test', 'double..dot.test', '*.*.example.test', + '[:::]', // IPv6-shaped characters, not an address + '[....]', + '[fd00::1', // never closed + 'møller.test/path', // punycoding must not smuggle a path through ]) { expect(() => normalizeRedirectHost(bad)).toThrow('Invalid redirect host'); } @@ -77,6 +91,7 @@ describe('normalizeRedirectHost', () => { 'https://app.example.test:8443/callback', '*.example.test', 'xn--80ak6aa92e.test', // punycode + 'møller.test', // and the same host written the way its owner spells it '*', ]) { expect(() => normalizeRedirectHost(good)).not.toThrow(); @@ -209,6 +224,45 @@ describe('redirect host validation (configured hosts)', () => { }); }); +describe('redirect URI schemes', () => { + // A script URI can borrow an allowed authority it never navigates to, so matching on the host + // alone let `javascript://localhost/…` through the guard the localhost check exists to be. + it('refuses a script scheme that parses with an allowed hostname', async () => { + const { app } = createTestApp(); + for (const uri of ['javascript://localhost/%0aalert(1)', 'javascript://127.0.0.1/%0aalert(1)']) { + const res = await app.request(`/data-integrations/salesforce/authorize?redirect_uri=${encodeURIComponent(uri)}`, { + redirect: 'manual', + }); + expect(res.status).toBe(400); + const body = await json(res); + expect(body.code).toBe('invalid_redirect_uri'); + expect(body.message).toContain('scheme javascript is not allowed'); + } + }); + + // `*` widens which host may be redirected to; it does not widen what a redirect may execute. + it('refuses script schemes even when any host is allowed', async () => { + const { app } = createTestApp(['*']); + for (const uri of ['javascript:alert(1)', 'data:text/html,', 'file:///etc/passwd']) { + const res = await app.request(`/data-integrations/salesforce/authorize?redirect_uri=${encodeURIComponent(uri)}`, { + redirect: 'manual', + }); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invalid_redirect_uri'); + } + }); + + // Native clients (RFC 8252) redirect to a custom scheme, which carries no script. + it('still allows a custom app scheme whose host is allowed', async () => { + const { app } = createTestApp(['callback']); + const res = await app.request( + `/data-integrations/salesforce/authorize?redirect_uri=${encodeURIComponent('myapp://callback/done')}`, + { redirect: 'manual' }, + ); + expect(res.status).toBe(302); + }); +}); + describe('createEmulator({ allowedRedirectHosts })', () => { it('applies the configured hosts, normalizing origins, and survives reset()', async () => { const emulator = await createEmulator({ port: 0, allowedRedirectHosts: ['https://app.example.test:8443'] }); @@ -245,3 +299,98 @@ describe('createEmulator({ allowedRedirectHosts })', () => { } }); }); + +/** + * The flag and the environment variable are the two ways a compose file or a CI command reaches + * this feature, and neither is exercised by anything above: `createEmulator` is handed a list + * that the CLI is responsible for splitting, collecting and preferring over the environment. + */ +describe('--redirect-hosts / WORKOS_EMULATE_REDIRECT_HOSTS', () => { + const CLI = new URL('../cli.ts', import.meta.url).pathname; + + /** + * The startup line only, not the whole stream: a served emulator never closes stdout, so + * reading to EOF would wait for the process this function is about to make requests against. + */ + async function readStartupLine(stream: ReadableStream): Promise { + const reader = stream.getReader(); + let buffered = ''; + try { + for (;;) { + const { value, done } = await reader.read(); + if (value) buffered += new TextDecoder().decode(value); + const line = buffered.split('\n').find((l) => l.startsWith('{')); + if (line && buffered.includes('\n')) return line; + if (done) throw new Error(`CLI printed no startup JSON: ${buffered}`); + } + } finally { + reader.releaseLock(); + } + } + + /** Start the CLI, hand its `--json` URL to `body`, and make sure the process is reaped. */ + async function withCli( + args: string[], + env: Record, + body: (url: string) => Promise, + ): Promise { + const proc = Bun.spawn([process.execPath, CLI, '--port', '0', '--json', ...args], { + env: { ...process.env, NO_UPDATE_NOTIFIER: '1', WORKOS_EMULATE_REDIRECT_HOSTS: '', ...env }, + stdout: 'pipe', + stderr: 'pipe', + }); + try { + const line = await readStartupLine(proc.stdout); + await body((JSON.parse(line) as { url: string }).url); + } finally { + proc.kill(); + await proc.exited; + } + } + + const authorize = (url: string, host: string) => + fetch(`${url}/data-integrations/salesforce/authorize?redirect_uri=https://${host}/cb`, { redirect: 'manual' }); + + it('collects hosts from repeated, comma-separated and inline forms of the flag', async () => { + await withCli( + ['--redirect-hosts', 'a.example.test,b.example.test', '--redirect-hosts=c.example.test'], + {}, + async (url) => { + for (const host of ['a.example.test', 'b.example.test', 'c.example.test']) { + expect((await authorize(url, host)).status).toBe(302); + } + expect((await authorize(url, 'd.example.test')).status).toBe(400); + }, + ); + }, 20000); + + it('reads the environment variable', async () => { + await withCli([], { WORKOS_EMULATE_REDIRECT_HOSTS: 'env.example.test, *.env.example.test' }, async (url) => { + expect((await authorize(url, 'env.example.test')).status).toBe(302); + expect((await authorize(url, 'sub.env.example.test')).status).toBe(302); + expect((await authorize(url, 'other.example.test')).status).toBe(400); + }); + }, 20000); + + it('prefers the flag over the environment', async () => { + await withCli( + ['--redirect-hosts', 'flag.example.test'], + { WORKOS_EMULATE_REDIRECT_HOSTS: 'env.example.test' }, + async (url) => { + expect((await authorize(url, 'flag.example.test')).status).toBe(302); + expect((await authorize(url, 'env.example.test')).status).toBe(400); + }, + ); + }, 20000); + + it('exits with the validation error rather than starting on an unmatchable host', async () => { + const proc = Bun.spawn([process.execPath, CLI, '--port', '0', '--json', '--redirect-hosts', 'https://'], { + env: { ...process.env, NO_UPDATE_NOTIFIER: '1' }, + stdout: 'pipe', + stderr: 'pipe', + }); + const stderr = await Bun.readableStreamToText(proc.stderr); + expect(await proc.exited).toBe(1); + expect(stderr).toContain('Invalid redirect host'); + }, 20000); +}); From 54ecb6817be5939e29183fd5b1fae3c23020b073 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 12:23:06 -0400 Subject: [PATCH 4/7] fix(redirects): canonicalize hosts to the form a request carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five review follow-ups, most of them one shape: a check that validated one spelling of a host while requests arrive in another. `isIPv6` accepts every legal way to write an address, but `URL.hostname` compresses and strips leading zeros, so `[FD00::0001]` passed validation, started the emulator and then matched nothing — the silent no-match this validation exists to turn into a loud failure. Both sides now reduce to the single form a request carries, optional trailing dot included. The strip is guarded so `*.` cannot collapse into `*` and widen to every host. Control characters are refused before parsing rather than after, because URL parsing removes them instead of failing on them: `http://localhost/` validated as localhost and then reached the Location header raw. A `--redirect-hosts` occurrence contributing no entries now exits instead of leaving an empty array behind, which is not nullish and so discarded WORKOS_EMULATE_REDIRECT_HOSTS on the way past. The unconfigured-host error named only the CLI flag, which a caller of createEmulator has no way to pass. --- README.md | 6 +++ src/cli.ts | 8 +++- src/workos/helpers.ts | 60 ++++++++++++++++++++++++- src/workos/redirect-hosts.spec.ts | 74 +++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 26a9826..665a43b 100644 --- a/README.md +++ b/README.md @@ -721,8 +721,14 @@ Notes: `/data-integrations/:slug/authorize`, and to `return_to` on `/user_management/sessions/logout`. - An internationalized hostname may be written either way: `møller.test` and its punycode (`xn--mller-vua.test`) normalize to the same entry, since that is the form a request carries. +- An IPv6 address may be written any legal way — `[FD00::0001]` and `[fd00:0:0:0:0:0:0:1]` are the + same entry as `[fd00::1]` — and a trailing dot is optional (`app.example.test.` matches + `app.example.test`). Both sides are reduced to the one form a request carries. - A host that could never match (`https://`, anything with whitespace) fails at startup rather than silently rejecting every request. +- A `redirect_uri` carrying an unencoded control character or space is a 400, since URL parsing + strips those rather than failing on them: `http://localhost/` would otherwise validate as + localhost and reach the `Location` header raw. - `javascript:`, `data:`, `vbscript:`, `blob:` and `file:` redirect URIs are always refused, `*` included — `javascript://localhost/…` parses with an allowed hostname it never navigates to. Custom app schemes (`myapp://callback`, for native clients) are allowed if their host is. diff --git a/src/cli.ts b/src/cli.ts index 5c3d87a..f872686 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -140,7 +140,13 @@ function parseArgs(argv: string[]): CliArgs { const value = arg === '--redirect-hosts' ? argv[++i] : arg.slice('--redirect-hosts='.length); if (!value) throw new Error('--redirect-hosts requires a value'); // Repeatable, and each occurrence may itself be a comma-separated list. - parsed.redirectHosts = [...(parsed.redirectHosts ?? []), ...splitHosts(value)]; + const hosts = splitHosts(value); + // A value that contributes no entries (',' or ' ') would leave an empty array, which is + // not nullish — so it would also discard WORKOS_EMULATE_REDIRECT_HOSTS on the way past. + // Two silent no-ops for the price of one, from a flag that was clearly meant to configure + // something. + if (hosts.length === 0) throw new Error('--redirect-hosts requires at least one host'); + parsed.redirectHosts = [...(parsed.redirectHosts ?? []), ...hosts]; continue; } diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 0ba063b..7e6b8c4 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -471,12 +471,44 @@ export function normalizeRedirectHost(value: string): string { if (!host.startsWith('[')) host = toAsciiHost(host); } + if (host.startsWith('[')) host = canonicalizeIpv6Host(host); + host = stripTrailingDot(host); + if (!isMatchableHostPattern(host)) { throw new Error(`Invalid redirect host: ${JSON.stringify(value)}`); } return host; } +/** + * Reduce a bracketed IPv6 literal to the one spelling `URL.hostname` produces. `isIPv6` accepts + * every legal way of writing an address, but a request for any of them arrives compressed and + * zero-stripped: `[fd00:0:0:0:0:0:0:1]` and `[FD00::0001]` both come through as `[fd00::1]`, and + * `[::ffff:127.0.0.1]` as `[::ffff:7f00:1]`. Validating without canonicalizing let an ordinary + * way of writing an address start the emulator and then match nothing — the same silent no-match + * the shape check exists to turn into a loud failure. Returns '' for anything `URL` refuses, + * which the shape check then rejects. + */ +function canonicalizeIpv6Host(host: string): string { + try { + return new URL(`http://${host}/`).hostname; + } catch { + return ''; + } +} + +/** + * Drop one trailing dot, so an absolute name is configured the way it is written. `URL.hostname` + * keeps the dot a request carried, so both sides are stripped and `app.example.test.` and + * `app.example.test` name the same host — otherwise neither spelling matched the other. + */ +function stripTrailingDot(host: string): string { + // Never `*.`, which is a wildcard missing its label rather than an absolute name: stripping + // there would turn a pattern that matches nothing into `*`, which matches everything. + if (host === `${ANY_HOST}.` || !host.endsWith('.')) return host; + return host.slice(0, -1); +} + /** * Convert an internationalized hostname to the punycode `URL.hostname` yields, so `møller.test` * can be configured the way its owner spells it. Without this, only the origin form @@ -529,6 +561,19 @@ export function normalizeRedirectHosts(values: readonly string[]): string[] { */ const SCRIPT_REDIRECT_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'blob:', 'file:']); +/** + * Whether a URI carries a character it may not contain unencoded: a C0 control, a space, or DEL. + * Written as a bound rather than a character-class regex, which is the thing a linter rightly + * asks about and reads less plainly than the range it stands for. + */ +function hasForbiddenUriChar(uri: string): boolean { + for (let i = 0; i < uri.length; i++) { + const code = uri.charCodeAt(i); + if (code <= 0x20 || code === 0x7f) return true; + } + return false; +} + function hostMatches(hostname: string, pattern: string): boolean { if (pattern === ANY_HOST) return true; // `*.example.test` covers subdomains only, matching how redirect allow-lists usually read. @@ -543,6 +588,14 @@ function hostMatches(hostname: string, pattern: string): boolean { * production-like hostnames. */ export function assertAllowedRedirectUri(uri: string, store: Store): void { + // Refused before parsing, because parsing *removes* these rather than failing on them: URL + // strips tabs and newlines and trims leading control characters, so `http://local\thost/` + // validates as localhost and then goes into the Location header raw, control character and + // all. Checking the parse result can only ever see the sanitized form. + if (hasForbiddenUriChar(uri)) { + throw new WorkOSApiError(400, 'Invalid redirect_uri', 'invalid_redirect_uri'); + } + let parsed: URL; try { parsed = new URL(uri); @@ -562,14 +615,17 @@ export function assertAllowedRedirectUri(uri: string, store: Store): void { const configured = store.getData(STORE_KEYS.allowedRedirectHosts) ?? []; const allowed = [...DEFAULT_ALLOWED_REDIRECT_HOSTS, ...configured]; - const hostname = parsed.hostname.toLowerCase(); + // Stripped on both sides, so an absolute name matches the entry it was configured as. + const hostname = stripTrailingDot(parsed.hostname.toLowerCase()); if (allowed.some((pattern) => hostMatches(hostname, pattern))) return; + // Names both ways in, since a programmatic caller has no flag to pass. + const howToWiden = 'Pass --redirect-hosts (or allowedRedirectHosts) to allow other hosts.'; throw new WorkOSApiError( 400, configured.length > 0 ? `redirect_uri host ${parsed.hostname} is not allowed; allowed hosts: ${allowed.join(', ')}` - : `redirect_uri must point to localhost, got ${parsed.hostname}. Pass --redirect-hosts to allow other hosts.`, + : `redirect_uri must point to localhost, got ${parsed.hostname}. ${howToWiden}`, 'invalid_redirect_uri', ); } diff --git a/src/workos/redirect-hosts.spec.ts b/src/workos/redirect-hosts.spec.ts index 080adce..0b37873 100644 --- a/src/workos/redirect-hosts.spec.ts +++ b/src/workos/redirect-hosts.spec.ts @@ -41,6 +41,24 @@ describe('normalizeRedirectHost', () => { expect(normalizeRedirectHost('[fd00::1]:3000')).toBe('[fd00::1]'); }); + // `isIPv6` accepts every legal spelling of an address, but a request only ever arrives in the + // one `URL.hostname` produces — so an entry that is not canonicalized starts the emulator and + // then matches nothing, the silent no-match this validation exists to prevent. + it('canonicalizes IPv6 to the single form a request carries', () => { + expect(normalizeRedirectHost('[fd00:0:0:0:0:0:0:1]')).toBe('[fd00::1]'); + expect(normalizeRedirectHost('[FD00::0001]')).toBe('[fd00::1]'); + expect(normalizeRedirectHost('fd00:0:0:0:0:0:0:1')).toBe('[fd00::1]'); + expect(normalizeRedirectHost('[::ffff:127.0.0.1]')).toBe('[::ffff:7f00:1]'); + expect(normalizeRedirectHost('https://[fd00:0:0:0:0:0:0:1]:8443')).toBe('[fd00::1]'); + }); + + // `URL.hostname` keeps the trailing dot a request carried, so both sides are stripped. + it('drops a trailing dot, so an absolute name matches the way it is written', () => { + expect(normalizeRedirectHost('app.example.test.')).toBe('app.example.test'); + expect(normalizeRedirectHost('*.example.test.')).toBe('*.example.test'); + expect(normalizeRedirectHost('https://app.example.test./cb')).toBe('app.example.test'); + }); + it('passes wildcards through', () => { expect(normalizeRedirectHost('*')).toBe('*'); expect(normalizeRedirectHost('*.example.test')).toBe('*.example.test'); @@ -75,6 +93,8 @@ describe('normalizeRedirectHost', () => { '[....]', '[fd00::1', // never closed 'møller.test/path', // punycoding must not smuggle a path through + '*..', // stripping the trailing dot must not quietly leave `*.` + '.', // nor turn a lone dot into an empty pattern that matches by accident ]) { expect(() => normalizeRedirectHost(bad)).toThrow('Invalid redirect host'); } @@ -112,6 +132,9 @@ describe('redirect host validation (default: localhost only)', () => { const body = await json(res); expect(body.code).toBe('invalid_redirect_uri'); expect(body.message).toContain('must point to localhost'); + // Names both ways in, since a programmatic caller has no flag to pass. + expect(body.message).toContain('--redirect-hosts'); + expect(body.message).toContain('allowedRedirectHosts'); }); it('rejects a non-localhost SSO redirect_uri', async () => { @@ -141,6 +164,23 @@ describe('redirect host validation (default: localhost only)', () => { expect(res.status).toBe(302); } }); + + // URL parsing *removes* these instead of failing on them, so `http://local\thost/` used to + // validate as localhost and then be handed to the redirect raw — a Location header carrying a + // control character, or a 500 where the URI was simply malformed. + it('refuses a URI carrying characters URL parsing would strip', async () => { + const raws = ['http://local\thost:3000/cb', 'http://localhost:3000/cb\r\nX-Injected: 1', 'http://loc\nalhost/cb']; + for (const raw of raws) { + const res = await app.request( + `/user_management/sessions/logout?session_id=session_x&return_to=${encodeURIComponent(raw)}`, + { redirect: 'manual' }, + ); + expect(res.status).toBe(400); + const body = await json(res); + expect(body.code).toBe('invalid_redirect_uri'); + expect(body.message).toBe('Invalid redirect_uri'); + } + }); }); describe('redirect host validation (configured hosts)', () => { @@ -194,6 +234,27 @@ describe('redirect host validation (configured hosts)', () => { expect(body.message).toContain('app.example.test'); }); + // A resolver-absolute name reaches `URL.hostname` with its dot intact, so a host configured + // without one has to match it anyway — otherwise the two spellings of the same host disagree. + it('matches an absolute (trailing-dot) request host against a dotless entry', async () => { + const { app } = createTestApp(['app.example.test']); + const res = await app.request('/data-integrations/salesforce/authorize?redirect_uri=https://app.example.test./cb', { + redirect: 'manual', + }); + expect(res.status).toBe(302); + }); + + it('accepts a canonicalized IPv6 host written any legal way', async () => { + // Through the real normalization the CLI and createEmulator both use — configuring the + // uncanonicalized literal directly is exactly the case that used to match nothing. + const { app } = createTestApp(normalizeRedirectHosts(['[FD00:0:0:0:0:0:0:1]'])); + const res = await app.request( + `/data-integrations/salesforce/authorize?redirect_uri=${encodeURIComponent('http://[fd00::1]:3000/cb')}`, + { redirect: 'manual' }, + ); + expect(res.status).toBe(302); + }); + it('matches subdomains of a wildcard, but not its apex', async () => { const { app } = createTestApp(['*.example.test']); @@ -383,6 +444,19 @@ describe('--redirect-hosts / WORKOS_EMULATE_REDIRECT_HOSTS', () => { ); }, 20000); + // An occurrence that contributes nothing left an empty array behind, which is not nullish and + // so also discarded the environment variable — a flag that configured nothing, twice over. + it('exits rather than accept a flag that contributes no hosts', async () => { + const proc = Bun.spawn([process.execPath, CLI, '--port', '0', '--json', '--redirect-hosts', ','], { + env: { ...process.env, NO_UPDATE_NOTIFIER: '1', WORKOS_EMULATE_REDIRECT_HOSTS: 'env.example.test' }, + stdout: 'pipe', + stderr: 'pipe', + }); + const stderr = await Bun.readableStreamToText(proc.stderr); + expect(await proc.exited).toBe(1); + expect(stderr).toContain('--redirect-hosts requires at least one host'); + }, 20000); + it('exits with the validation error rather than starting on an unmatchable host', async () => { const proc = Bun.spawn([process.execPath, CLI, '--port', '0', '--json', '--redirect-hosts', 'https://'], { env: { ...process.env, NO_UPDATE_NOTIFIER: '1' }, From 3fa14d113beb3bacec34b1abb001dab9a7f19660 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 14:18:20 -0400 Subject: [PATCH 5/7] fix(redirects): match the guard's shape to what it claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review follow-ups, all the shape of the last round: a check whose stated invariant did not hold at the edges. `*` widens which host may be redirected to, never what a redirect may execute — but the scheme check is a denylist, and a denylist is always one scheme short. `view-source:javascript:alert(1)`, `jar:` and `about:` all parse with an empty hostname, so the host check had nothing to say about them and `*` waved them into a Location header on an authority they never had. Refused by shape now, so the guard does not depend on having enumerated every script-bearing scheme. That also refuses RFC 8252's path-only `com.example.app:/cb`, which never worked here either; the `myapp://callback` form is unaffected. An underscored host failed at startup on a config that works. `URL` passes an underscore through untouched, so `my_host.example.test` is a host a request really does arrive with — the shape a compose service name takes, which is what the environment variable exists to serve. Refusing it was the mirror of the bug the last round fixed: a loud failure where nothing was wrong. `*:3000` and `https://*` reduced to the fully-open `*`. Both read as a narrowing and are not one, since ports and schemes are never part of the host check. It is the same accident `stripTrailingDot` already guards for `*.`, and the one way this feature could hand back a weaker default than the operator asked for. Space is out of the forbidden-character bound. It cannot split a header, so it bought nothing there, and `searchParams.get` decodes `+` to a space — which turned an unencoded `+` in the inner query, the shape base64 state and a `+` in an email both take, into a 400 on a redirect that had worked. --- README.md | 20 ++++++---- src/workos/helpers.ts | 43 +++++++++++++++++---- src/workos/redirect-hosts.spec.ts | 62 +++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 665a43b..fa7bd62 100644 --- a/README.md +++ b/README.md @@ -724,14 +724,20 @@ Notes: - An IPv6 address may be written any legal way — `[FD00::0001]` and `[fd00:0:0:0:0:0:0:1]` are the same entry as `[fd00::1]` — and a trailing dot is optional (`app.example.test.` matches `app.example.test`). Both sides are reduced to the one form a request carries. +- An underscore is fine (`my_host.example.test`, the shape a Docker Compose service name takes). + It is not DNS-conformant, but a request really does arrive carrying it. - A host that could never match (`https://`, anything with whitespace) fails at startup rather than - silently rejecting every request. -- A `redirect_uri` carrying an unencoded control character or space is a 400, since URL parsing - strips those rather than failing on them: `http://localhost/` would otherwise validate as - localhost and reach the `Location` header raw. + silently rejecting every request. So does an entry that would only reduce to `*` by having + something stripped off it (`*:3000`, `https://*`) — `*` means every host, and it may only be + spelled that way rather than arrived at by accident. +- A `redirect_uri` carrying an unencoded control character is a 400, since URL parsing strips those + rather than failing on them: `http://localhost/` would otherwise validate as localhost and + reach the `Location` header raw. - `javascript:`, `data:`, `vbscript:`, `blob:` and `file:` redirect URIs are always refused, `*` - included — `javascript://localhost/…` parses with an allowed hostname it never navigates to. - Custom app schemes (`myapp://callback`, for native clients) are allowed if their host is. + included — `javascript://localhost/…` parses with an allowed hostname it never navigates to. So + is any URI with no authority at all (`view-source:javascript:…`, `jar:`, `about:blank`), which + the host check has nothing to say about and `*` would otherwise wave through. Custom app schemes + (`myapp://callback`, for native clients) keep their host and are allowed if that host is. ## Error Hooks @@ -1051,7 +1057,7 @@ The WorkOS Emulator is designed for testing and development environments. When u ### Network Security - **Bind to localhost**: By default, the emulator binds to `localhost`, so its unauthenticated endpoints are only reachable from the local machine. To intentionally expose it to other hosts, pass `--host 0.0.0.0` (CLI) or `hostname: '0.0.0.0'` (`createEmulator`), and protect it with a firewall or VPN. -- **Open redirect protection**: The authorize endpoints only redirect to localhost by default. `--redirect-hosts` (or `allowedRedirectHosts`) widens that for test environments with production-like hostnames; `--redirect-hosts '*'` disables the host check entirely, so only use it on an emulator nothing untrusted can reach. Script-bearing schemes (`javascript:`, `data:`) are refused regardless. See [Redirect URI Hosts](#redirect-uri-hosts). +- **Open redirect protection**: The authorize endpoints only redirect to localhost by default. `--redirect-hosts` (or `allowedRedirectHosts`) widens that for test environments with production-like hostnames; `--redirect-hosts '*'` disables the host check entirely, so only use it on an emulator nothing untrusted can reach. Script-bearing schemes (`javascript:`, `data:`) and URIs with no authority for the host check to speak about (`view-source:javascript:…`, `about:`) are refused regardless. See [Redirect URI Hosts](#redirect-uri-hosts). - **No CORS restrictions**: The emulator doesn't enforce CORS. Configure CORS in your application if needed. - **No TLS/SSL**: The emulator doesn't provide HTTPS. Use a reverse proxy (nginx, Caddy) for TLS termination in production. diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 7e6b8c4..4deaa4e 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -474,6 +474,14 @@ export function normalizeRedirectHost(value: string): string { if (host.startsWith('[')) host = canonicalizeIpv6Host(host); host = stripTrailingDot(host); + // A literal `*` returned above, so arriving at one here means something was stripped off it: + // `*:3000` and `https://*` both reduce to the fully-open wildcard, and both read as a + // narrowing — ports and schemes are never part of the host check. Refused rather than quietly + // widened, the same accident `stripTrailingDot` guards for `*.`. + if (host === ANY_HOST) { + throw new Error(`Invalid redirect host: ${JSON.stringify(value)} — write "*" to allow any host`); + } + if (!isMatchableHostPattern(host)) { throw new Error(`Invalid redirect host: ${JSON.stringify(value)}`); } @@ -526,8 +534,13 @@ function toAsciiHost(host: string): string { return wildcard ? `*.${ascii}` : ascii; } -/** A DNS label: alphanumeric, inner hyphens allowed, dot-separated. */ -const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; +/** + * A label as `URL.hostname` may carry it: alphanumeric or underscore, inner hyphens allowed, + * dot-separated. Underscore is not DNS-conformant but `URL` passes it through untouched, so a + * Docker service name like `my_host` is a host a request really does arrive with — the question + * here is whether a pattern can ever equal a `URL.hostname`, not whether a resolver would like it. + */ +const HOSTNAME = /^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?)*$/; /** * Whether a normalized pattern can ever match a `URL.hostname`. Checking only for emptiness and @@ -562,14 +575,18 @@ export function normalizeRedirectHosts(values: readonly string[]): string[] { const SCRIPT_REDIRECT_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'blob:', 'file:']); /** - * Whether a URI carries a character it may not contain unencoded: a C0 control, a space, or DEL. - * Written as a bound rather than a character-class regex, which is the thing a linter rightly - * asks about and reads less plainly than the range it stands for. + * Whether a URI carries a character it may not contain unencoded: a C0 control or DEL. Written as + * a bound rather than a character-class regex, which is the thing a linter rightly asks about and + * reads less plainly than the range it stands for. + * + * Space (0x20) is deliberately outside the bound. It cannot split a header, so it buys nothing + * here, and `searchParams.get` decodes `+` to a space — so including it turned an unencoded `+` + * in the inner query (base64 state, a `+` in an email) into a 400 on a redirect that had worked. */ function hasForbiddenUriChar(uri: string): boolean { for (let i = 0; i < uri.length; i++) { const code = uri.charCodeAt(i); - if (code <= 0x20 || code === 0x7f) return true; + if (code < 0x20 || code === 0x7f) return true; } return false; } @@ -605,7 +622,19 @@ export function assertAllowedRedirectUri(uri: string, store: Store): void { // Checked before the host, and regardless of configuration: `*` widens which host may be // redirected to, never what a redirect is allowed to execute. - if (SCRIPT_REDIRECT_SCHEMES.has(parsed.protocol)) { + // + // The empty hostname is half of that, and the half a denylist cannot cover. A URI with no + // authority is one the host check has nothing to say about, so `*` — which only ever answers + // "is this host allowed" — waved `view-source:javascript:alert(1)`, `jar:` and `about:` through + // on an authority they never had. Refused by shape, so the guard does not depend on having + // enumerated every script-bearing scheme. Custom app schemes keep their host and are + // unaffected in the `myapp://callback` form. + // + // This does refuse RFC 8252's other private-use spelling, the path-only `com.example.app:/cb`, + // which has no authority to check either. It has never been allowed here (the localhost-only + // guard rejected it too), and allowing it would mean deciding by heuristic which hostless URIs + // nest another one — so it stays out until something actually needs it. + if (SCRIPT_REDIRECT_SCHEMES.has(parsed.protocol) || parsed.hostname === '') { throw new WorkOSApiError( 400, `redirect_uri scheme ${parsed.protocol.slice(0, -1)} is not allowed`, diff --git a/src/workos/redirect-hosts.spec.ts b/src/workos/redirect-hosts.spec.ts index 0b37873..46d9092 100644 --- a/src/workos/redirect-hosts.spec.ts +++ b/src/workos/redirect-hosts.spec.ts @@ -100,6 +100,17 @@ describe('normalizeRedirectHost', () => { } }); + // `*` is the one entry that widens to every host, so it may only be spelled that way. Stripping + // a port or a scheme off something else and landing on it turns what reads as a narrowing into + // the fully-open wildcard — the accident `stripTrailingDot` already guards for `*.`. + it('refuses a wildcard that only became bare `*` by having something stripped off it', () => { + for (const bad of ['*:3000', 'https://*', 'https://*:8443', '*:0']) { + expect(() => normalizeRedirectHost(bad)).toThrow('write "*" to allow any host'); + } + // The one spelling that does mean every host still works. + expect(normalizeRedirectHost('*')).toBe('*'); + }); + it('still accepts the forms it documents', () => { for (const good of [ 'localhost', @@ -112,6 +123,11 @@ describe('normalizeRedirectHost', () => { '*.example.test', 'xn--80ak6aa92e.test', // punycode 'møller.test', // and the same host written the way its owner spells it + // Not DNS-conformant, but `URL` passes underscores through, so a compose service name is a + // host a request really arrives with — rejecting it failed loudly on a config that works. + 'my_host.example.test', + '_dmarc.example.test', + '*.my_host.example.test', '*', ]) { expect(() => normalizeRedirectHost(good)).not.toThrow(); @@ -181,6 +197,18 @@ describe('redirect host validation (default: localhost only)', () => { expect(body.message).toBe('Invalid redirect_uri'); } }); + + // Only the characters that can actually split a header are refused. A space cannot, and + // `searchParams.get` decodes `+` to one — so rejecting 0x20 turned an unencoded `+` in the + // inner query (base64 state, a `+` in an email) into a 400 on a redirect that had worked. + it('allows a space where a raw `+` in the inner query decoded to one', async () => { + const res = await app.request( + '/data-integrations/salesforce/authorize?redirect_uri=http://localhost:3000/cb?token=YWJj+ZGVm', + { redirect: 'manual' }, + ); + expect(res.status).toBe(302); + expect(res.headers.get('Location')).toContain('token=YWJj+ZGVm'); + }); }); describe('redirect host validation (configured hosts)', () => { @@ -244,6 +272,19 @@ describe('redirect host validation (configured hosts)', () => { expect(res.status).toBe(302); }); + // `URL` passes an underscore through untouched, so a compose service name is a host a request + // really does arrive with — refusing to configure it failed loudly on a config that works. + it('matches an underscored host, which URL.hostname carries verbatim', async () => { + const { app } = createTestApp(normalizeRedirectHosts(['my_host.example.test'])); + const res = await app.request( + '/data-integrations/salesforce/authorize?redirect_uri=http://my_host.example.test/cb', + { + redirect: 'manual', + }, + ); + expect(res.status).toBe(302); + }); + it('accepts a canonicalized IPv6 host written any legal way', async () => { // Through the real normalization the CLI and createEmulator both use — configuring the // uncanonicalized literal directly is exactly the case that used to match nothing. @@ -313,6 +354,27 @@ describe('redirect URI schemes', () => { } }); + // The denylist covers the schemes it names, and a denylist is always one scheme short. Each of + // these parses with an empty hostname, so `*` — which only ever answers "is this host allowed" + // — waved them through on an authority they never had. Refused by shape now, not by name. + it('refuses a URI with no authority for the host check to speak about', async () => { + const { app } = createTestApp(['*']); + for (const uri of [ + 'view-source:javascript:alert(1)', + 'jar:http://localhost/!/x', + 'about:blank', + 'mailto:a@b.test', + ]) { + const res = await app.request(`/data-integrations/salesforce/authorize?redirect_uri=${encodeURIComponent(uri)}`, { + redirect: 'manual', + }); + expect(res.status).toBe(400); + const body = await json(res); + expect(body.code).toBe('invalid_redirect_uri'); + expect(body.message).toContain('is not allowed'); + } + }); + // Native clients (RFC 8252) redirect to a custom scheme, which carries no script. it('still allows a custom app scheme whose host is allowed', async () => { const { app } = createTestApp(['callback']); From 0cd6825614c2b6dd9f9c33cfbc127baa180419bc Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:01:38 -0400 Subject: [PATCH 6/7] fix(redirects): canonicalize IPv4 and check earlier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review follow-ups, the first of them the same shape as the last round: a check whose stated invariant did not hold at the edges. `URL` rewrites every IPv4 shorthand it accepts, so `10.1`, `192.168.001.1` and `2130706433` validated by shape, started the emulator and then matched nothing — the silent no-match this validation exists to turn into a loud failure, surviving in the one address family people actually type. Canonicalizing runs after the shape check rather than before it, because `URL` drops a path instead of failing on one and would otherwise hand back a clean host for `app.example.test/path`. The second shape check catches what canonicalizing destroys: `999.999.999.999` is IPv4-shaped and not an address, so it now fails at startup rather than never matching either. Logout echoed `return_to` into the `Location` header verbatim while the three authorize endpoints re-serialized theirs. A space is legal in the URI a request carries — `searchParams.get` decodes a raw `+` into one — and is not legal in the header it produces. Interactive mode renders the redirect_uri into a hidden field and left every check to the POST, so a host the emulator will not redirect to served a working sign-in form and refused only after someone had filled it in. --- README.md | 5 ++- src/workos/helpers.ts | 38 ++++++++++++++-- src/workos/redirect-hosts.spec.ts | 75 +++++++++++++++++++++++++++++++ src/workos/routes/auth.ts | 5 +++ src/workos/routes/sessions.ts | 5 ++- src/workos/routes/sso.ts | 5 +++ 6 files changed, 127 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fa7bd62..846b461 100644 --- a/README.md +++ b/README.md @@ -721,8 +721,9 @@ Notes: `/data-integrations/:slug/authorize`, and to `return_to` on `/user_management/sessions/logout`. - An internationalized hostname may be written either way: `møller.test` and its punycode (`xn--mller-vua.test`) normalize to the same entry, since that is the form a request carries. -- An IPv6 address may be written any legal way — `[FD00::0001]` and `[fd00:0:0:0:0:0:0:1]` are the - same entry as `[fd00::1]` — and a trailing dot is optional (`app.example.test.` matches +- An IP address may be written any legal way. `[FD00::0001]` and `[fd00:0:0:0:0:0:0:1]` are the same + entry as `[fd00::1]`; `10.1`, `192.168.001.1` and `2130706433` are the same entries as `10.0.0.1`, + `192.168.1.1` and `127.0.0.1`. A trailing dot is optional too (`app.example.test.` matches `app.example.test`). Both sides are reduced to the one form a request carries. - An underscore is fine (`my_host.example.test`, the shape a Docker Compose service name takes). It is not DNS-conformant, but a request really does arrive carrying it. diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 4deaa4e..5589fce 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -482,12 +482,44 @@ export function normalizeRedirectHost(value: string): string { throw new Error(`Invalid redirect host: ${JSON.stringify(value)} — write "*" to allow any host`); } - if (!isMatchableHostPattern(host)) { - throw new Error(`Invalid redirect host: ${JSON.stringify(value)}`); - } + // Shape first, then canonicalize, then shape again. `URL` drops a path rather than failing on + // one, so `app.example.test/path` has to be refused before anything is allowed to reshape it — + // and canonicalizing can itself turn a plausible-looking entry into nothing at all. + if (!isMatchableHostPattern(host)) throw invalidRedirectHost(value); + host = canonicalizeDnsHost(host); + if (!isMatchableHostPattern(host)) throw invalidRedirectHost(value); return host; } +function invalidRedirectHost(value: string): Error { + return new Error(`Invalid redirect host: ${JSON.stringify(value)}`); +} + +/** + * Reduce a validated non-bracketed pattern to the one spelling `URL.hostname` produces, the way + * `canonicalizeIpv6Host` already does for addresses. `URL` rewrites IPv4 in every shorthand it + * accepts — `10.1`, `192.168.001.1`, `0x7f.0.0.1` and `2130706433` all arrive as their dotted-quad + * form — so an entry written any of those ways validated by shape, started the emulator and then + * matched nothing: the silent no-match the rest of this validation exists to turn into a loud + * failure. Only ever called on a pattern `isMatchableHostPattern` has accepted, so there is no path + * or port left for `URL` to strip. Returns '' for what `URL` refuses (`999.999.999.999`, + * `1.2.3.4.5` — IPv4-shaped and not an address), which the second shape check then rejects. + */ +function canonicalizeDnsHost(host: string): string { + // Bracketed literals came through `canonicalizeIpv6Host` already. + if (host.startsWith('[')) return host; + // `*` is not a host `URL` should be asked about, so convert only what it stands in front of. + const wildcard = host.startsWith('*.'); + const bare = wildcard ? host.slice(2) : host; + let canonical: string; + try { + canonical = new URL(`http://${bare}/`).hostname; + } catch { + return ''; + } + return wildcard ? `*.${canonical}` : canonical; +} + /** * Reduce a bracketed IPv6 literal to the one spelling `URL.hostname` produces. `isIPv6` accepts * every legal way of writing an address, but a request for any of them arrives compressed and diff --git a/src/workos/redirect-hosts.spec.ts b/src/workos/redirect-hosts.spec.ts index 46d9092..19367ea 100644 --- a/src/workos/redirect-hosts.spec.ts +++ b/src/workos/redirect-hosts.spec.ts @@ -52,6 +52,24 @@ describe('normalizeRedirectHost', () => { expect(normalizeRedirectHost('https://[fd00:0:0:0:0:0:0:1]:8443')).toBe('[fd00::1]'); }); + // The same asymmetry as IPv6, in the family people actually type: `URL` rewrites every IPv4 + // shorthand it accepts, so an entry written any way but dotted-quad matched nothing. + it('canonicalizes IPv4 shorthand to the dotted quad a request carries', () => { + expect(normalizeRedirectHost('10.1')).toBe('10.0.0.1'); + expect(normalizeRedirectHost('192.168.001.1')).toBe('192.168.1.1'); + expect(normalizeRedirectHost('0x7f.0.0.1')).toBe('127.0.0.1'); + expect(normalizeRedirectHost('2130706433')).toBe('127.0.0.1'); + expect(normalizeRedirectHost('https://10.1:8443/cb')).toBe('10.0.0.1'); + expect(normalizeRedirectHost('*.10.1')).toBe('*.10.0.0.1'); + }); + + // IPv4-shaped and not an address: `URL` refuses these, so they can never be a `URL.hostname`. + it('rejects an IPv4-shaped host that is not an address', () => { + for (const bad of ['999.999.999.999', '1.2.3.4.5', '256.0.0.1']) { + expect(() => normalizeRedirectHost(bad)).toThrow('Invalid redirect host'); + } + }); + // `URL.hostname` keeps the trailing dot a request carried, so both sides are stripped. it('drops a trailing dot, so an absolute name matches the way it is written', () => { expect(normalizeRedirectHost('app.example.test.')).toBe('app.example.test'); @@ -209,6 +227,19 @@ describe('redirect host validation (default: localhost only)', () => { expect(res.status).toBe(302); expect(res.headers.get('Location')).toContain('token=YWJj+ZGVm'); }); + + // A space is legal in the `return_to` a request carries and not in the `Location` it produces, + // so logout re-serializes rather than echoing — the way the authorize endpoints already did. + it('encodes a space out of the logout Location instead of echoing it raw', async () => { + const res = await app.request( + '/user_management/sessions/logout?session_id=session_x&return_to=http://localhost:3000/cb?token=YWJj+ZGVm', + { redirect: 'manual' }, + ); + expect(res.status).toBe(302); + const location = res.headers.get('Location')!; + expect(location).not.toContain(' '); + expect(location).toBe('http://localhost:3000/cb?token=YWJj%20ZGVm'); + }); }); describe('redirect host validation (configured hosts)', () => { @@ -296,6 +327,16 @@ describe('redirect host validation (configured hosts)', () => { expect(res.status).toBe(302); }); + it('accepts an IPv4 host written in any shorthand URL accepts', async () => { + const { app } = createTestApp(normalizeRedirectHosts(['10.1'])); + for (const host of ['10.1', '10.0.0.1', '0xa.0.0.1']) { + const res = await app.request(`/data-integrations/salesforce/authorize?redirect_uri=http://${host}/cb`, { + redirect: 'manual', + }); + expect(res.status).toBe(302); + } + }); + it('matches subdomains of a wildcard, but not its apex', async () => { const { app } = createTestApp(['*.example.test']); @@ -386,6 +427,40 @@ describe('redirect URI schemes', () => { }); }); +/** + * Interactive mode renders the redirect_uri into a hidden field and only reaches the host check on + * the POST, so a disallowed host used to serve a working sign-in form and fail after the form was + * filled in — the same check, arriving a page too late. + */ +describe('redirect host validation (interactive mode)', () => { + function createInteractiveApp(allowedRedirectHosts?: string[]) { + const { app, store } = createTestApp(allowedRedirectHosts); + store.setData(STORE_KEYS.interactiveAuth, true); + return app; + } + + const paths = [ + '/user_management/authorize?redirect_uri=https://app.example.test/cb', + '/sso/authorize?connection=conn_x&redirect_uri=https://app.example.test/cb', + ]; + + it('refuses a disallowed host at the GET rather than rendering a login page', async () => { + for (const path of paths) { + const res = await createInteractiveApp().request(path); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invalid_redirect_uri'); + } + }); + + it('still renders the login page when the host is allowed', async () => { + for (const path of paths) { + const res = await createInteractiveApp(['app.example.test']).request(path); + expect(res.status).toBe(200); + expect(await res.text()).toContain('value="https://app.example.test/cb"'); + } + }); +}); + describe('createEmulator({ allowedRedirectHosts })', () => { it('applies the configured hosts, normalizing origins, and survives reset()', async () => { const emulator = await createEmulator({ port: 0, allowedRedirectHosts: ['https://app.example.test:8443'] }); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index a5f11f5..6c3f9f9 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -120,6 +120,11 @@ export function authRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'redirect_uri is required', 'invalid_request'); } + // Checked before the interactive branch, which renders the redirect_uri into a hidden field + // and defers every check to the POST. A host the emulator will not redirect to should fail + // here, not after someone has filled the form in. + assertAllowedRedirectUri(redirectUri, store); + const interactive = store.getData(STORE_KEYS.interactiveAuth); if (interactive) { const hiddenFields: Record = { redirect_uri: redirectUri }; diff --git a/src/workos/routes/sessions.ts b/src/workos/routes/sessions.ts index ee98fa2..12c3001 100644 --- a/src/workos/routes/sessions.ts +++ b/src/workos/routes/sessions.ts @@ -52,7 +52,10 @@ export function sessionRoutes(ctx: RouteContext): void { if (returnTo) { assertAllowedRedirectUri(returnTo, store); - return c.redirect(returnTo); + // Re-serialized rather than echoed, the way the authorize endpoints already emit theirs. A + // space is a legal `return_to` character (`searchParams.get` decodes a raw `+` into one) but + // not a legal `Location` one, so the raw string would put an unencoded space in the header. + return c.redirect(new URL(returnTo).toString()); } return c.json({ success: true }); }); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 9460e41..0e83e9a 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -93,6 +93,11 @@ export function ssoRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'Missing required parameter: redirect_uri', 'invalid_request'); } + // Checked before the interactive branch, which renders the redirect_uri into a hidden field + // and defers every check to the POST. A host the emulator will not redirect to should fail + // here, not after someone has filled the form in. + assertAllowedRedirectUri(redirectUri, store); + const interactive = store.getData(STORE_KEYS.interactiveAuth); if (interactive) { const hiddenFields: Record = { redirect_uri: redirectUri }; From 8f77854818321a3387111eee8f9d58009ea83e71 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:01:48 -0400 Subject: [PATCH 7/7] fix(emulator): re-apply option data after reset() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `store.reset()` drops every data entry, so an option written into the store at startup stops taking effect after the first reset and nothing says so. Redirect hosts were restored; `interactiveAuth`, `webhookRetryConfig` and `webhookDebugMode` were not — so reset() quietly returned an interactive emulator to serving redirects instead of login pages. Kept in one closure so the next option written here cannot become half of a pair again. --- src/index.ts | 26 ++++++++++---------------- src/workos/interactive-auth.spec.ts | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index d9c63c8..ab7afd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,25 +124,19 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise { + + // store.reset() drops every data entry, so anything set from `options` has to be re-applied + // from reset() as well. Kept in one place because the failure is silent otherwise: an option + // set here and not restored there simply stops taking effect after the first reset. + const applyOptionData = () => { + if (options.interactiveAuth) store.setData(STORE_KEYS.interactiveAuth, true); if (allowedRedirectHosts.length > 0) store.setData(STORE_KEYS.allowedRedirectHosts, allowedRedirectHosts); + if (options.webhookRetryConfig) store.setData('webhookRetryConfig', options.webhookRetryConfig); + if (options.webhookDebugMode) store.setData('webhookDebugMode', true); }; - applyRedirectHosts(); - - if (options.webhookRetryConfig) { - store.setData('webhookRetryConfig', options.webhookRetryConfig); - } - - if (options.webhookDebugMode) { - store.setData('webhookDebugMode', true); - } + applyOptionData(); // Health check endpoint app.get('/health', (c) => c.json({ status: 'ok' })); @@ -242,7 +236,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise { expect(html).toContain(email); }); }); + +/** + * `store.reset()` drops every data entry, interactive mode's flag included, so the option has to + * be re-applied afterwards. Without that, reset() silently returned the emulator to serving + * redirects — the option stopped taking effect and nothing said so. + */ +describe('Interactive Auth Mode after reset()', () => { + it('keeps serving login pages', async () => { + const emulator = await createEmulator({ port: 0, interactiveAuth: true }); + try { + const login = () => + fetch(`${emulator.url}/user_management/authorize?redirect_uri=http://localhost:3000/callback`, { + redirect: 'manual', + }); + + expect((await login()).status).toBe(200); + emulator.reset(); + const after = await login(); + expect(after.status).toBe(200); + expect(after.headers.get('content-type')).toContain('text/html'); + } finally { + await emulator.close(); + } + }); +});