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 b5400fbe1..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.httpUrl(), + 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 3eaf6bf3b..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.httpUrl(), + next: httpUrlSchema, }) export type ConfirmEmailInput = z.infer