diff --git a/apps/sim/app/api/attribution/route.ts b/apps/sim/app/api/attribution/route.ts index be77c7732..43d6c882b 100644 --- a/apps/sim/app/api/attribution/route.ts +++ b/apps/sim/app/api/attribution/route.ts @@ -18,6 +18,7 @@ import { eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { cookies } from 'next/headers' import { NextResponse } from 'next/server' +import { z } from 'zod' import { getSession } from '@/lib/auth' import { applyBonusCredits } from '@/lib/billing/credits/bonus' @@ -26,11 +27,21 @@ const logger = createLogger('AttributionAPI') const COOKIE_NAME = 'sim_utm' const CLOCK_DRIFT_TOLERANCE_MS = 60 * 1000 +const UtmCookieSchema = z.object({ + utm_source: z.string().optional(), + utm_medium: z.string().optional(), + utm_campaign: z.string().optional(), + utm_content: z.string().optional(), + referrer_url: z.string().optional(), + landing_page: z.string().optional(), + created_at: z.string().min(1), +}) + /** * Finds the most specific active campaign matching the given UTM params. * Null fields on a campaign act as wildcards. Ties broken by newest campaign. */ -async function findMatchingCampaign(utmData: Record) { +async function findMatchingCampaign(utmData: z.infer) { const campaigns = await db .select() .from(referralCampaigns) @@ -89,16 +100,15 @@ export async function POST() { return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' }) } - let utmData: Record + let utmData: z.infer try { - // Decode first, falling back to raw value if UTM params contain bare % let decoded: string try { decoded = decodeURIComponent(utmCookie.value) } catch { decoded = utmCookie.value } - utmData = JSON.parse(decoded) + utmData = UtmCookieSchema.parse(JSON.parse(decoded)) } catch { logger.warn('Failed to parse UTM cookie', { userId: session.user.id }) cookieStore.delete(COOKIE_NAME) @@ -106,8 +116,8 @@ export async function POST() { } const cookieCreatedAt = Number(utmData.created_at) - if (!cookieCreatedAt || !Number.isFinite(cookieCreatedAt)) { - logger.warn('UTM cookie missing created_at timestamp', { userId: session.user.id }) + if (!Number.isFinite(cookieCreatedAt)) { + logger.warn('UTM cookie has invalid created_at timestamp', { userId: session.user.id }) cookieStore.delete(COOKIE_NAME) return NextResponse.json({ attributed: false, reason: 'invalid_cookie' }) } diff --git a/apps/sim/app/api/referral-code/redeem/route.ts b/apps/sim/app/api/referral-code/redeem/route.ts index 401bc5e73..cc6c09493 100644 --- a/apps/sim/app/api/referral-code/redeem/route.ts +++ b/apps/sim/app/api/referral-code/redeem/route.ts @@ -20,12 +20,17 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { NextResponse } from 'next/server' +import { z } from 'zod' import { getSession } from '@/lib/auth' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { applyBonusCredits } from '@/lib/billing/credits/bonus' const logger = createLogger('ReferralCodeRedemption') +const RedeemCodeSchema = z.object({ + code: z.string().min(1, 'Code is required'), +}) + export async function POST(request: Request) { try { const session = await getSession() @@ -34,11 +39,7 @@ export async function POST(request: Request) { } const body = await request.json() - const { code } = body - - if (!code || typeof code !== 'string') { - return NextResponse.json({ error: 'Code is required' }, { status: 400 }) - } + const { code } = RedeemCodeSchema.parse(body) const subscription = await getHighestPrioritySubscription(session.user.id) @@ -160,6 +161,9 @@ export async function POST(request: Request) { bonusAmount, }) } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } logger.error('Referral code redemption error', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) }