diff --git a/.changeset/tidy-jars-arrive.md b/.changeset/tidy-jars-arrive.md new file mode 100644 index 0000000..a2e4620 --- /dev/null +++ b/.changeset/tidy-jars-arrive.md @@ -0,0 +1,28 @@ +--- +'@seamless-auth/types': minor +--- + +Add `magic_link_redirect_uris` to the system config. + +An exact-match allowlist of destinations a magic link may be sent to, empty by +default. `seamless-auth-api` validates a requested destination against `origins` +today, which covers a target whose host is already a WebAuthn origin and cannot +express the two cases that need this: a custom application scheme such as +`myapp://auth`, and a universal link on a host that should not also be a WebAuthn +origin. + +Exact match rather than origin comparison, because neither of those has an origin +worth comparing. Empty by default, so a deployment that sets nothing keeps comparing +against `origins` exactly as it does now. + +Also exports `RedirectTargetSchema`, which is what entries are validated with, and +which is stricter than `z.url()` on purpose. `z.url()` accepts anything the URL parser +does, including `javascript:alert(1)` and `data:text/html,...`. A magic link +destination is rendered as an href in an email, so one of those stored in config would +be a script-execution sink reachable through the admin system-config API. The +`javascript:`, `data:`, `vbscript:`, `file:`, `blob:` and `about:` schemes are refused, +and everything else including arbitrary application schemes is allowed, since an +allowlist of known-good schemes could not express the case this exists for. + +`SystemConfigPatchSchema` takes the field too, so the guard applies to the admin write +path and not only to what a server seeds at boot. diff --git a/src/schemas/systemConfig/schema.test.ts b/src/schemas/systemConfig/schema.test.ts index 8818f96..8dc455e 100644 --- a/src/schemas/systemConfig/schema.test.ts +++ b/src/schemas/systemConfig/schema.test.ts @@ -76,6 +76,62 @@ describe('OAuthProviderUpdateSchema', () => { }); }); +describe('magic_link_redirect_uris', () => { + it('defaults to empty, so a config predating the key parses unchanged', () => { + expect(SystemConfigSchema.parse(baseConfig).magic_link_redirect_uris).toEqual([]); + }); + + it('accepts an application scheme, which is the case it exists for', () => { + const parsed = SystemConfigSchema.parse({ + ...baseConfig, + magic_link_redirect_uris: ['myapp://auth/magic', 'com.example.app://callback'], + }); + + expect(parsed.magic_link_redirect_uris).toEqual([ + 'myapp://auth/magic', + 'com.example.app://callback', + ]); + }); + + it('accepts a universal link on a host that is not a configured origin', () => { + const parsed = SystemConfigSchema.parse({ + ...baseConfig, + magic_link_redirect_uris: ['https://links.example.com/m'], + }); + + expect(parsed.magic_link_redirect_uris).toEqual(['https://links.example.com/m']); + }); + + // z.url() alone accepts these. A magic link target is rendered as an href in an + // email, so a javascript: or data: entry reachable through the admin API would be a + // script-execution sink. + it.each(['javascript:alert(1)', 'data:text/html,', 'file:///etc/passwd'])( + 'rejects %s', + (uri) => { + expect( + SystemConfigSchema.safeParse({ ...baseConfig, magic_link_redirect_uris: [uri] }).success, + ).toBe(false); + }, + ); + + it('rejects a value that is not a URL at all', () => { + expect( + SystemConfigSchema.safeParse({ ...baseConfig, magic_link_redirect_uris: ['not a url'] }) + .success, + ).toBe(false); + }); + + it('guards the patch surface the admin API writes through', () => { + expect( + SystemConfigPatchSchema.safeParse({ magic_link_redirect_uris: ['javascript:alert(1)'] }) + .success, + ).toBe(false); + expect( + SystemConfigPatchSchema.safeParse({ magic_link_redirect_uris: ['myapp://auth'] }).success, + ).toBe(true); + }); +}); + describe('SystemConfigSchema', () => { it('applies the default lockout policy', () => { const parsed = SystemConfigSchema.parse(baseConfig); diff --git a/src/schemas/systemConfig/schema.ts b/src/schemas/systemConfig/schema.ts index c595442..0b34d12 100644 --- a/src/schemas/systemConfig/schema.ts +++ b/src/schemas/systemConfig/schema.ts @@ -11,6 +11,38 @@ export const LoginMethodSchema = z.enum([ export type LoginMethod = z.infer; +/** + * Schemes that must never be a redirect target. + * + * `z.url()` accepts anything the URL parser does, which includes + * `javascript:alert(1)` and `data:text/html,...`. A magic link target is rendered as + * an href in an email, so one of those stored in config would be a script-execution + * sink reachable through the admin API. Denied rather than allowlisted because the + * point of this list is to permit arbitrary application schemes (`myapp://`), which + * an allowlist of known-good schemes could not express. + */ +const DENIED_REDIRECT_PROTOCOLS = new Set([ + 'javascript:', + 'data:', + 'vbscript:', + 'file:', + 'blob:', + 'about:', +]); + +export const RedirectTargetSchema = z.url().refine( + (value) => { + try { + return !DENIED_REDIRECT_PROTOCOLS.has(new URL(value).protocol.toLowerCase()); + } catch { + return false; + } + }, + { message: 'Redirect target uses a scheme that cannot be a link destination' }, +); + +export type RedirectTarget = z.infer; + export const OAuthProviderIdSchema = z.string().regex(/^[a-z0-9-]{2,40}$/); export type OAuthProviderId = z.infer; @@ -212,6 +244,17 @@ export const SystemConfigSchema = z.object({ origins: z.array(z.url()).min(1), frontend_url: z.url().optional(), + + /** + * Exact-match destinations a magic link may be sent to, beyond what `origins` + * already covers. Empty by default, which leaves the server comparing a requested + * target against `origins` as it did before this key existed. + * + * Exact match rather than origin comparison, because the cases this exists for have + * no origin to compare: a custom scheme such as `myapp://auth`, or a universal link + * on a host that should not also be a WebAuthn origin. + */ + magic_link_redirect_uris: z.array(RedirectTargetSchema).default([]), }); export type SystemConfig = z.infer; @@ -235,6 +278,7 @@ export const SystemConfigPatchSchema = z delay_after: SystemConfigSchema.shape.delay_after.optional(), rpid: SystemConfigSchema.shape.rpid.optional(), origins: SystemConfigSchema.shape.origins.optional(), + magic_link_redirect_uris: z.array(RedirectTargetSchema).optional(), }) .strict();