diff --git a/README.md b/README.md index 2d84a00..846b461 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,66 @@ 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 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. +- 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. +- A host that could never match (`https://`, anything with whitespace) fails at startup rather than + 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. 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 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 +1058,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:`) 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/cli.ts b/src/cli.ts index e43d35e..f872686 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,20 @@ 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. + 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; + } + if (arg === '--seed' || arg === '-s') { const value = argv[++i]; if (!value) throw new Error(`${arg} requires a value`); @@ -156,6 +177,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 +270,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 +278,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..ab7afd5 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; @@ -115,17 +124,19 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise { + 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); + }; + applyOptionData(); // Health check endpoint app.get('/health', (c) => c.json({ status: 'ok' })); @@ -225,6 +236,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise 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:']); + +/** + * 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; + } + 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. + if (pattern.startsWith('*.')) return hostname.endsWith(pattern.slice(1)); + return hostname === pattern; +} + +/** + * 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 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); } catch { throw new WorkOSApiError(400, 'Invalid redirect_uri', 'invalid_redirect_uri'); } - if (!ALLOWED_REDIRECT_HOSTS.has(parsed.hostname)) { + + // Checked before the host, and regardless of configuration: `*` widens which host may be + // redirected to, never what a redirect is allowed to execute. + // + // 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 must point to localhost, got ${parsed.hostname}`, + `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]; + // 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}. ${howToWiden}`, + 'invalid_redirect_uri', + ); } const AUTH_CHALLENGE_EXCLUDE = new Set([...INTERNAL_FIELDS, 'code']); diff --git a/src/workos/interactive-auth.spec.ts b/src/workos/interactive-auth.spec.ts index 78d7f55..7b8bc65 100644 --- a/src/workos/interactive-auth.spec.ts +++ b/src/workos/interactive-auth.spec.ts @@ -178,3 +178,28 @@ describe('Interactive Auth Mode', () => { 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(); + } + }); +}); diff --git a/src/workos/redirect-hosts.spec.ts b/src/workos/redirect-hosts.spec.ts new file mode 100644 index 0000000..19367ea --- /dev/null +++ b/src/workos/redirect-hosts.spec.ts @@ -0,0 +1,607 @@ +/** + * 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]'); + }); + + // `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]'); + }); + + // 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'); + 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'); + }); + + it('rejects input that could never match', () => { + expect(() => normalizeRedirectHost('https://')).toThrow('Invalid redirect host'); + 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 + '*.', // 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', + '[:::]', // IPv6-shaped characters, not an address + '[....]', + '[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'); + } + }); + + // `*` 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', + '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 + '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(); + } + }); + + 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'); + // 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 () => { + 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); + } + }); + + // 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'); + } + }); + + // 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'); + }); + + // 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)', () => { + 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'); + }); + + // 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); + }); + + // `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. + 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('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']); + + 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('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'); + } + }); + + // 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']); + const res = await app.request( + `/data-integrations/salesforce/authorize?redirect_uri=${encodeURIComponent('myapp://callback/done')}`, + { redirect: 'manual' }, + ); + expect(res.status).toBe(302); + }); +}); + +/** + * 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'] }); + 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(); + } + }); +}); + +/** + * 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); + + // 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' }, + stdout: 'pipe', + stderr: 'pipe', + }); + const stderr = await Bun.readableStreamToText(proc.stderr); + expect(await proc.exited).toBe(1); + expect(stderr).toContain('Invalid redirect host'); + }, 20000); +}); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 985f6e4..6c3f9f9 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) { @@ -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/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..12c3001 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,8 +51,11 @@ export function sessionRoutes(ctx: RouteContext): void { } if (returnTo) { - assertLocalRedirectUri(returnTo); - return c.redirect(returnTo); + assertAllowedRedirectUri(returnTo, store); + // 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 f1b6e43..0e83e9a 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; @@ -87,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 };