From 6a4e23cd589ed915b3429445a5dcb026c50ceae3 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Thu, 14 May 2026 16:57:17 +0200 Subject: [PATCH] fix(auth): allow localhost URLs in email-confirm next param --- src/app/api/auth/confirm/route.ts | 3 +- src/core/modules/auth/models.ts | 3 +- src/core/shared/schemas/url.ts | 13 ++++++ tests/unit/url-schema.test.ts | 73 +++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/unit/url-schema.test.ts diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index c622fec9d..a979690d0 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -4,12 +4,13 @@ import { z } from 'zod' import { AUTH_URLS } from '@/configs/urls' import { OtpTypeSchema } from '@/core/modules/auth/models' import { l } from '@/core/shared/clients/logger/logger' +import { httpUrlSchema } from '@/core/shared/schemas/url' import { encodedRedirect, isExternalOrigin } from '@/lib/utils/auth' const confirmSchema = z.object({ token_hash: z.string().min(1), type: OtpTypeSchema, - next: z.httpUrl(), + next: httpUrlSchema, }) /** diff --git a/src/core/modules/auth/models.ts b/src/core/modules/auth/models.ts index 3eaf6bf3b..604fa8b50 100644 --- a/src/core/modules/auth/models.ts +++ b/src/core/modules/auth/models.ts @@ -1,4 +1,5 @@ import z from 'zod' +import { httpUrlSchema } from '@/core/shared/schemas/url' export const OtpTypeSchema = z.enum([ 'signup', @@ -14,7 +15,7 @@ export type OtpType = z.infer export const ConfirmEmailInputSchema = z.object({ token_hash: z.string().min(1), type: OtpTypeSchema, - next: z.httpUrl(), + next: httpUrlSchema, }) export type ConfirmEmailInput = z.infer diff --git a/src/core/shared/schemas/url.ts b/src/core/shared/schemas/url.ts index 9dda0d9ac..3b8f59305 100644 --- a/src/core/shared/schemas/url.ts +++ b/src/core/shared/schemas/url.ts @@ -1,5 +1,18 @@ import { z } from 'zod' +/** + * Validates that a string is a well-formed HTTP or HTTPS URL. + * + * Unlike `z.httpUrl()`, this also accepts localhost / 127.0.0.1 URLs so the + * email-verification flow works against a local Supabase setup in development. + * + * The schema only validates URL structure — redirect safety is enforced + * downstream by `isExternalOrigin()` and `buildRedirectUrl()` in the auth + * route handlers, which reconstruct the redirect using the dashboard's own + * origin and preserve only `pathname` + `searchParams` from the input. + */ +export const httpUrlSchema = z.url({ protocol: /^https?$/ }) + export const relativeUrlSchema = z .string() .trim() diff --git a/tests/unit/url-schema.test.ts b/tests/unit/url-schema.test.ts new file mode 100644 index 000000000..c6a7e37d9 --- /dev/null +++ b/tests/unit/url-schema.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { httpUrlSchema } from '@/core/shared/schemas/url' + +describe('httpUrlSchema', () => { + describe('accepts valid http/https URLs', () => { + it('accepts https production URLs', () => { + expect(httpUrlSchema.safeParse('https://e2b.dev/dashboard').success).toBe( + true + ) + }) + + it('accepts https URLs with paths and query params', () => { + expect( + httpUrlSchema.safeParse('https://e2b.dev/dashboard?tab=settings') + .success + ).toBe(true) + }) + + it('accepts http localhost URLs', () => { + expect( + httpUrlSchema.safeParse('http://localhost:3000/dashboard').success + ).toBe(true) + }) + + it('accepts http localhost without port', () => { + expect(httpUrlSchema.safeParse('http://localhost').success).toBe(true) + }) + + it('accepts http 127.0.0.1 URLs', () => { + expect(httpUrlSchema.safeParse('http://127.0.0.1:3000').success).toBe( + true + ) + }) + + it('accepts https URLs with subdomains', () => { + expect( + httpUrlSchema.safeParse('https://app.e2b.dev/dashboard').success + ).toBe(true) + }) + }) + + describe('rejects non-http(s) schemes', () => { + it('rejects mailto URLs', () => { + expect(httpUrlSchema.safeParse('mailto:user@example.com').success).toBe( + false + ) + }) + + it('rejects ftp URLs', () => { + expect(httpUrlSchema.safeParse('ftp://files.example.com').success).toBe( + false + ) + }) + + it('rejects file URLs', () => { + expect(httpUrlSchema.safeParse('file:///etc/passwd').success).toBe(false) + }) + + it('rejects javascript URLs', () => { + expect(httpUrlSchema.safeParse('javascript:alert(1)').success).toBe(false) + }) + }) + + describe('rejects invalid inputs', () => { + it('rejects plain strings', () => { + expect(httpUrlSchema.safeParse('not-a-url').success).toBe(false) + }) + + it('rejects empty strings', () => { + expect(httpUrlSchema.safeParse('').success).toBe(false) + }) + }) +})