diff --git a/.cursor/rules/file-format-preference.mdc b/.cursor/rules/file-format-preference.mdc deleted file mode 100644 index d226cdf2..00000000 --- a/.cursor/rules/file-format-preference.mdc +++ /dev/null @@ -1,6 +0,0 @@ ---- -globs: .tsx,.jsx -alwaysApply: false ---- - -Prefer TypeScript over JavaScript when creating components and so forth. diff --git a/src/app/actions.ts b/src/app/actions.ts index c80ea86f..7a521b73 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -1,6 +1,7 @@ "use server"; import { validateName } from "@/lib/formValidation"; +import { getSafeHttpReferrer } from "@/utils/referrer"; import { createClient } from "@/utils/supabase/server"; import { getBaseUrl } from "@/utils/url"; import { @@ -28,7 +29,9 @@ export const signUpAction = async (formData: FormData, request?: Request) => { const origin = headersList.get("origin"); // Get attribution data - const referrer = formData.get("initial_referrer")?.toString(); + const referrer = getSafeHttpReferrer( + formData.get("initial_referrer")?.toString() + ); const utmSource = formData.get("utm_source")?.toString(); const utmMedium = formData.get("utm_medium")?.toString(); const utmCampaign = formData.get("utm_campaign")?.toString(); diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index dc7d1276..274f1d87 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,5 +1,5 @@ import { createClient } from "@/utils/supabase/server"; -import { appendSuccessParam, normalizeNextPath } from "@/utils/authRedirects"; +import { appendSuccessParam, normaliseNextPath } from "@/utils/authRedirects"; import { NextResponse } from "next/server"; const isAuthDebugEnabled = process.env.NEXT_PUBLIC_AUTH_DEBUG === "true"; @@ -23,7 +23,7 @@ export async function GET(request: Request) { const requestedNextPath = requestUrl.searchParams.get("next") ?? requestUrl.searchParams.get("redirect_to"); - const nextPath = normalizeNextPath(requestedNextPath, "/profile"); + const nextPath = normaliseNextPath(requestedNextPath, "/profile"); if (code) { const supabase = await createClient(); diff --git a/src/app/auth/confirm/route.ts b/src/app/auth/confirm/route.ts index 9966a3e2..6f517fff 100644 --- a/src/app/auth/confirm/route.ts +++ b/src/app/auth/confirm/route.ts @@ -3,7 +3,7 @@ import { appendSuccessParam, getDefaultNextPathByType, isSupportedEmailAuthType, - normalizeNextPath, + normaliseNextPath, } from "@/utils/authRedirects"; import { NextResponse } from "next/server"; @@ -35,7 +35,7 @@ export async function GET(request: Request) { requestUrl.searchParams.get("next") ?? requestUrl.searchParams.get("redirect_to"); const defaultNextPath = getDefaultNextPathByType(authType); - const nextPath = normalizeNextPath(requestedNextPath, defaultNextPath); + const nextPath = normaliseNextPath(requestedNextPath, defaultNextPath); if (!tokenHash || !isSupportedEmailAuthType(authType)) { debugAuth("invalid-confirm-query", { diff --git a/src/app/auth/session/route.ts b/src/app/auth/session/route.ts index fa3f5200..fa682398 100644 --- a/src/app/auth/session/route.ts +++ b/src/app/auth/session/route.ts @@ -3,7 +3,7 @@ import { appendSuccessParam, getDefaultNextPathByType, isSupportedEmailAuthType, - normalizeNextPath, + normaliseNextPath, } from "@/utils/authRedirects"; import { NextResponse } from "next/server"; @@ -54,7 +54,7 @@ export async function POST(request: Request) { } const defaultNextPath = getDefaultNextPathByType(type); - const nextPath = normalizeNextPath( + const nextPath = normaliseNextPath( typeof body?.next === "string" ? body.next : null, defaultNextPath ); diff --git a/src/components/AuthHashCompletion.tsx b/src/components/AuthHashCompletion.tsx index 6ff09d45..6a9f2835 100644 --- a/src/components/AuthHashCompletion.tsx +++ b/src/components/AuthHashCompletion.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { getDefaultNextPathByType, isSupportedEmailAuthType, - normalizeNextPath, + normaliseNextPath, } from "@/utils/authRedirects"; const INVALID_LINK_MESSAGE = @@ -37,7 +37,7 @@ export default function AuthHashCompletion() { queryParams.get("next") ?? queryParams.get("redirect_to"); const requestedType = queryParams.get("type") ?? hashType; const defaultNextPath = getDefaultNextPathByType(requestedType); - const nextPath = normalizeNextPath(preferredNextPath, defaultNextPath); + const nextPath = normaliseNextPath(preferredNextPath, defaultNextPath); const authCode = queryParams.get("code"); const hasAuthHashPayload = @@ -126,7 +126,7 @@ export default function AuthHashCompletion() { return; } - const typedNextPath = normalizeNextPath( + const typedNextPath = normaliseNextPath( preferredNextPath, getDefaultNextPathByType(type) ); @@ -177,7 +177,7 @@ export default function AuthHashCompletion() { return; } - const resolvedNextPath = normalizeNextPath(data.next, typedNextPath); + const resolvedNextPath = normaliseNextPath(data.next, typedNextPath); debugAuth("session-finalized", { type, nextPath: resolvedNextPath, diff --git a/src/proxy.ts b/src/proxy.ts index b86b1641..87c6afdb 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -36,17 +36,13 @@ export async function proxy(request: NextRequest) { externalReferrer && request.method === "GET" ) { - response.cookies.set( - INITIAL_REFERRER_COOKIE, - encodeURIComponent(externalReferrer), - { - httpOnly: false, - maxAge: INITIAL_REFERRER_MAX_AGE, - path: "/", - sameSite: "lax", - secure: request.nextUrl.protocol === "https:", - } - ); + response.cookies.set(INITIAL_REFERRER_COOKIE, externalReferrer, { + httpOnly: false, + maxAge: INITIAL_REFERRER_MAX_AGE, + path: "/", + sameSite: "lax", + secure: request.nextUrl.protocol === "https:", + }); } return response; diff --git a/src/utils/attributionUtils.ts b/src/utils/attributionUtils.ts index ded500b7..af1fbc90 100644 --- a/src/utils/attributionUtils.ts +++ b/src/utils/attributionUtils.ts @@ -1,5 +1,7 @@ "use client"; +import { normaliseReferrer } from "@/utils/referrer"; + const UTM_STORAGE_KEY = "attribution_params"; const INITIAL_REFERRER_KEY = "initial_referrer"; const INITIAL_REFERRER_COOKIE = "initial_referrer"; @@ -46,11 +48,12 @@ const getCookie = (name: string): string | null => { if (!value) return null; - try { - return decodeURIComponent(value); - } catch { - return value; - } + const cleanedValue = normaliseReferrer(value); + const trimmedValue = cleanedValue.trim(); + + if (!trimmedValue) return null; + + return trimmedValue; }; const getExternalDocumentReferrer = (): string | null => { @@ -72,7 +75,7 @@ const getExternalDocumentReferrer = (): string | null => { }; const storeInitialReferrer = (referrer: string) => { - localStorage.setItem(INITIAL_REFERRER_KEY, referrer); + localStorage.setItem(INITIAL_REFERRER_KEY, normaliseReferrer(referrer)); }; export function captureAttributionParams() { @@ -125,9 +128,18 @@ export function getStoredAttributionParams(): StoredAttributionParams { const stored = localStorage.getItem(UTM_STORAGE_KEY); const storedInitialReferrer = localStorage.getItem(INITIAL_REFERRER_KEY); const cookieReferrer = getCookie(INITIAL_REFERRER_COOKIE); - const initialReferrer = storedInitialReferrer ?? cookieReferrer; + const initialReferrer = + storedInitialReferrer !== null + ? normaliseReferrer(storedInitialReferrer) + : cookieReferrer; - if (!storedInitialReferrer && initialReferrer) { + if (storedInitialReferrer === null && initialReferrer) { + storeInitialReferrer(initialReferrer); + } else if ( + storedInitialReferrer !== null && + initialReferrer && + initialReferrer !== storedInitialReferrer + ) { storeInitialReferrer(initialReferrer); } diff --git a/src/utils/authRedirects.ts b/src/utils/authRedirects.ts index aac2022b..021203d9 100644 --- a/src/utils/authRedirects.ts +++ b/src/utils/authRedirects.ts @@ -24,7 +24,7 @@ export const getDefaultNextPathByType = (type: string | null | undefined) => { return "/profile"; }; -export const normalizeNextPath = ( +export const normaliseNextPath = ( candidatePath: string | null | undefined, fallbackPath: string ) => { @@ -49,8 +49,8 @@ export const normalizeNextPath = ( }; export const appendSuccessParam = (path: string, successValue: string) => { - const normalizedPath = normalizeNextPath(path, "/profile"); - const url = new URL(normalizedPath, "https://www.peels.app"); + const normalisedPath = normaliseNextPath(path, "/profile"); + const url = new URL(normalisedPath, "https://www.peels.app"); url.searchParams.set("success", successValue); return `${url.pathname}${url.search}${url.hash}`; }; diff --git a/src/utils/referrer.ts b/src/utils/referrer.ts new file mode 100644 index 00000000..20b10fd6 --- /dev/null +++ b/src/utils/referrer.ts @@ -0,0 +1,47 @@ +const encodedReferrerPrefix = /^https?%(?:25)*3a%(?:25)*2f%(?:25)*2f/i; +const controlCharacters = /[\u0000-\u001f\u007f]/g; +const MAX_REFERRER_LENGTH = 512; + +export function normaliseReferrer(referrer: string): string; +export function normaliseReferrer(referrer: undefined): undefined; +export function normaliseReferrer( + referrer: string | undefined +): string | undefined; +export function normaliseReferrer(referrer: string | undefined) { + if (referrer === undefined) return undefined; + + let current = referrer.trim(); + + for (let i = 0; i < 3 && encodedReferrerPrefix.test(current); i += 1) { + try { + const decoded = decodeURIComponent(current); + if (decoded === current) break; + current = decoded; + } catch { + break; + } + } + + return current.replace(controlCharacters, "").slice(0, MAX_REFERRER_LENGTH); +} + +export function getSafeHttpReferrer(referrer: string | undefined) { + const cleanedReferrer = normaliseReferrer(referrer)?.trim(); + + if (!cleanedReferrer) return undefined; + + try { + const referrerUrl = new URL(cleanedReferrer); + + if (!["http:", "https:"].includes(referrerUrl.protocol)) { + return undefined; + } + + referrerUrl.search = ""; + referrerUrl.hash = ""; + + return referrerUrl.toString().slice(0, MAX_REFERRER_LENGTH); + } catch { + return undefined; + } +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index a500a07d..874e4dde 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,7 +1,7 @@ const PRODUCTION_SUPABASE_HOST = "mfnaqdyunuafbwukbbyr.supabase.co"; const STORAGE_PUBLIC_PATH = "/storage/v1/object/public"; -function normalizeAssetPath(assetPath: string) { +function normaliseAssetPath(assetPath: string) { return assetPath.replace(/^\/+/, ""); } @@ -22,7 +22,7 @@ export function getStoragePublicUrl(bucket: string, assetPath: string) { if (!supabaseUrl) return null; - return `${supabaseUrl.replace(/\/$/, "")}${STORAGE_PUBLIC_PATH}/${bucket}/${normalizeAssetPath(assetPath)}`; + return `${supabaseUrl.replace(/\/$/, "")}${STORAGE_PUBLIC_PATH}/${bucket}/${normaliseAssetPath(assetPath)}`; } export function usesHostedStaticAssets() { @@ -43,7 +43,7 @@ export function getStaticAssetUrl( export function getStaticFontUrl(assetPath: string) { return getStoragePublicUrl( "static", - `fonts/${normalizeAssetPath(assetPath)}` + `fonts/${normaliseAssetPath(assetPath)}` ); }