Skip to content

Commit 7cd9181

Browse files
authored
fix(auth): allow localhost URLs in email-confirm next param (#327)
## Summary Fixes [#241](#241): Zod validation error blocks email verification on local-dev Supabase setups. - Replaces `z.httpUrl()` with a shared `httpUrlSchema` that uses `z.url({ protocol: /^https?$/ })` — still requires http/https, but accepts `http://localhost:3000/...`. - Schema lives in `src/core/shared/schemas/url.ts` alongside the existing `relativeUrlSchema`, and both the auth confirm route handler and `ConfirmEmailInputSchema` consume it. - Webhook URL validation (`src/core/server/functions/webhooks/schema.ts`) intentionally kept on `z.httpUrl()` — webhooks should not accept localhost. ## Files changed - `src/core/shared/schemas/url.ts` — adds `httpUrlSchema` alongside existing `relativeUrlSchema` - `src/core/modules/auth/models.ts` — `ConfirmEmailInputSchema.next` uses the shared schema - `src/app/api/auth/confirm/route.ts` — same for the route's inline `confirmSchema` - `tests/unit/url-schema.test.ts` *(new)* — 12 unit tests covering accept/reject cases ## Tests ``` bun run test:unit bun run lint ``` ## Credits Adapted from #249 by @taheerahmed
1 parent 5f688d3 commit 7cd9181

4 files changed

Lines changed: 90 additions & 2 deletions

File tree

src/app/api/auth/confirm/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { z } from 'zod'
44
import { AUTH_URLS } from '@/configs/urls'
55
import { OtpTypeSchema } from '@/core/modules/auth/models'
66
import { l } from '@/core/shared/clients/logger/logger'
7+
import { httpUrlSchema } from '@/core/shared/schemas/url'
78
import { encodedRedirect, isExternalOrigin } from '@/lib/utils/auth'
89

910
const confirmSchema = z.object({
1011
token_hash: z.string().min(1),
1112
type: OtpTypeSchema,
12-
next: z.httpUrl(),
13+
next: httpUrlSchema,
1314
})
1415

1516
/**

src/core/modules/auth/models.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import z from 'zod'
2+
import { httpUrlSchema } from '@/core/shared/schemas/url'
23

34
export const OtpTypeSchema = z.enum([
45
'signup',
@@ -14,7 +15,7 @@ export type OtpType = z.infer<typeof OtpTypeSchema>
1415
export const ConfirmEmailInputSchema = z.object({
1516
token_hash: z.string().min(1),
1617
type: OtpTypeSchema,
17-
next: z.httpUrl(),
18+
next: httpUrlSchema,
1819
})
1920

2021
export type ConfirmEmailInput = z.infer<typeof ConfirmEmailInputSchema>

src/core/shared/schemas/url.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
import { z } from 'zod'
22

3+
/**
4+
* Validates that a string is a well-formed HTTP or HTTPS URL.
5+
*
6+
* Unlike `z.httpUrl()`, this also accepts localhost / 127.0.0.1 URLs so the
7+
* email-verification flow works against a local Supabase setup in development.
8+
*
9+
* The schema only validates URL structure — redirect safety is enforced
10+
* downstream by `isExternalOrigin()` and `buildRedirectUrl()` in the auth
11+
* route handlers, which reconstruct the redirect using the dashboard's own
12+
* origin and preserve only `pathname` + `searchParams` from the input.
13+
*/
14+
export const httpUrlSchema = z.url({ protocol: /^https?$/ })
15+
316
export const relativeUrlSchema = z
417
.string()
518
.trim()

tests/unit/url-schema.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { httpUrlSchema } from '@/core/shared/schemas/url'
3+
4+
describe('httpUrlSchema', () => {
5+
describe('accepts valid http/https URLs', () => {
6+
it('accepts https production URLs', () => {
7+
expect(httpUrlSchema.safeParse('https://e2b.dev/dashboard').success).toBe(
8+
true
9+
)
10+
})
11+
12+
it('accepts https URLs with paths and query params', () => {
13+
expect(
14+
httpUrlSchema.safeParse('https://e2b.dev/dashboard?tab=settings')
15+
.success
16+
).toBe(true)
17+
})
18+
19+
it('accepts http localhost URLs', () => {
20+
expect(
21+
httpUrlSchema.safeParse('http://localhost:3000/dashboard').success
22+
).toBe(true)
23+
})
24+
25+
it('accepts http localhost without port', () => {
26+
expect(httpUrlSchema.safeParse('http://localhost').success).toBe(true)
27+
})
28+
29+
it('accepts http 127.0.0.1 URLs', () => {
30+
expect(httpUrlSchema.safeParse('http://127.0.0.1:3000').success).toBe(
31+
true
32+
)
33+
})
34+
35+
it('accepts https URLs with subdomains', () => {
36+
expect(
37+
httpUrlSchema.safeParse('https://app.e2b.dev/dashboard').success
38+
).toBe(true)
39+
})
40+
})
41+
42+
describe('rejects non-http(s) schemes', () => {
43+
it('rejects mailto URLs', () => {
44+
expect(httpUrlSchema.safeParse('mailto:user@example.com').success).toBe(
45+
false
46+
)
47+
})
48+
49+
it('rejects ftp URLs', () => {
50+
expect(httpUrlSchema.safeParse('ftp://files.example.com').success).toBe(
51+
false
52+
)
53+
})
54+
55+
it('rejects file URLs', () => {
56+
expect(httpUrlSchema.safeParse('file:///etc/passwd').success).toBe(false)
57+
})
58+
59+
it('rejects javascript URLs', () => {
60+
expect(httpUrlSchema.safeParse('javascript:alert(1)').success).toBe(false)
61+
})
62+
})
63+
64+
describe('rejects invalid inputs', () => {
65+
it('rejects plain strings', () => {
66+
expect(httpUrlSchema.safeParse('not-a-url').success).toBe(false)
67+
})
68+
69+
it('rejects empty strings', () => {
70+
expect(httpUrlSchema.safeParse('').success).toBe(false)
71+
})
72+
})
73+
})

0 commit comments

Comments
 (0)