Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/tidy-jars-arrive.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions src/schemas/systemConfig/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<script>x</script>', '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);
Expand Down
44 changes: 44 additions & 0 deletions src/schemas/systemConfig/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,38 @@ export const LoginMethodSchema = z.enum([

export type LoginMethod = z.infer<typeof LoginMethodSchema>;

/**
* 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<typeof RedirectTargetSchema>;

export const OAuthProviderIdSchema = z.string().regex(/^[a-z0-9-]{2,40}$/);

export type OAuthProviderId = z.infer<typeof OAuthProviderIdSchema>;
Expand Down Expand Up @@ -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<typeof SystemConfigSchema>;
Expand All @@ -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();

Expand Down