From 14a91a3f6aca99299bf938d7cb30f483543aa36a Mon Sep 17 00:00:00 2001 From: Taheer Date: Thu, 26 Feb 2026 03:22:44 +0530 Subject: [PATCH 1/2] Fix: Replace z.httpUrl() with z.url() constrained to http/https for localhost compatibility z.httpUrl() rejects localhost URLs, causing Zod validation errors during local development when verifying email. z.url() with a protocol constraint still restricts to http/https while accepting localhost. Existing isExternalOrigin checks already handle redirect security. Closes #241 --- src/app/api/auth/confirm/route.ts | 2 +- src/server/api/models/auth.models.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index b5400fbe1..7d83d30b5 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -9,7 +9,7 @@ import { z } from 'zod' const confirmSchema = z.object({ token_hash: z.string().min(1), type: OtpTypeSchema, - next: z.httpUrl(), + next: z.url({ protocol: /^https?$/ }), }) /** diff --git a/src/server/api/models/auth.models.ts b/src/server/api/models/auth.models.ts index 3eaf6bf3b..82eeb0880 100644 --- a/src/server/api/models/auth.models.ts +++ b/src/server/api/models/auth.models.ts @@ -14,7 +14,7 @@ export type OtpType = z.infer export const ConfirmEmailInputSchema = z.object({ token_hash: z.string().min(1), type: OtpTypeSchema, - next: z.httpUrl(), + next: z.url({ protocol: /^https?$/ }), }) export type ConfirmEmailInput = z.infer From 5fe2522672a584562e70d92f7b51942a3ad1e975 Mon Sep 17 00:00:00 2001 From: Taheer Date: Sat, 28 Feb 2026 02:48:24 +0530 Subject: [PATCH 2/2] Refactor: Abstract httpUrlSchema into lib/schemas/url.ts and add unit tests - Extract shared httpUrlSchema into src/lib/schemas/url.ts alongside existing relativeUrlSchema - Both auth confirm route and ConfirmEmailInputSchema now import from the shared schema instead of inlining z.url({ protocol: /^https?$/ }) - Add 12 unit tests covering http/https acceptance, localhost support, non-http scheme rejection, and invalid input handling --- src/__test__/unit/url-schema.test.ts | 75 ++++++++++++++++++++++++++++ src/app/api/auth/confirm/route.ts | 3 +- src/lib/schemas/url.ts | 6 +++ src/server/api/models/auth.models.ts | 3 +- 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 src/__test__/unit/url-schema.test.ts diff --git a/src/__test__/unit/url-schema.test.ts b/src/__test__/unit/url-schema.test.ts new file mode 100644 index 000000000..f089d8256 --- /dev/null +++ b/src/__test__/unit/url-schema.test.ts @@ -0,0 +1,75 @@ +import { httpUrlSchema } from '@/lib/schemas/url' +import { describe, expect, it } from 'vitest' + +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) + }) + }) +}) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 7d83d30b5..2e659aa2f 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -1,6 +1,7 @@ import { AUTH_URLS } from '@/configs/urls' import { l } from '@/lib/clients/logger/logger' import { encodedRedirect, isExternalOrigin } from '@/lib/utils/auth' +import { httpUrlSchema } from '@/lib/schemas/url' import { OtpTypeSchema } from '@/server/api/models/auth.models' import { redirect } from 'next/navigation' import { NextRequest } from 'next/server' @@ -9,7 +10,7 @@ import { z } from 'zod' const confirmSchema = z.object({ token_hash: z.string().min(1), type: OtpTypeSchema, - next: z.url({ protocol: /^https?$/ }), + next: httpUrlSchema, }) /** diff --git a/src/lib/schemas/url.ts b/src/lib/schemas/url.ts index 9dda0d9ac..cb7358288 100644 --- a/src/lib/schemas/url.ts +++ b/src/lib/schemas/url.ts @@ -1,5 +1,11 @@ import { z } from 'zod' +/** + * Validates that a string is a well-formed HTTP or HTTPS URL. + * Unlike z.httpUrl(), this also accepts localhost URLs for local development. + */ +export const httpUrlSchema = z.url({ protocol: /^https?$/ }) + export const relativeUrlSchema = z .string() .trim() diff --git a/src/server/api/models/auth.models.ts b/src/server/api/models/auth.models.ts index 82eeb0880..8c3d5cca3 100644 --- a/src/server/api/models/auth.models.ts +++ b/src/server/api/models/auth.models.ts @@ -1,4 +1,5 @@ import z from 'zod' +import { httpUrlSchema } from '@/lib/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.url({ protocol: /^https?$/ }), + next: httpUrlSchema, }) export type ConfirmEmailInput = z.infer