diff --git a/CLAUDE.md b/CLAUDE.md index aceb572c..9089c249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ Defined in `src/content/config.ts` using Astro's content layer with loaders: **Redirect Flow:** 1. User hits short URL (e.g., `/go`) 2. Middleware finds redirect config -3. Redirects to `/redirect?source=/go&dest=/contact&type=temporary&...` (307) +3. Redirects to `/redirect?source=/go&...` (307). The interstitial resolves the destination for `source` itself (`resolveRedirect()` in `src/data/promoRedirects.ts`) and never reads a destination from the query string, so it can't be used as an open redirect; unknown sources go to `/`. 4. Interstitial page (`src/pages/redirect.astro`) tracks event via GA4/Datadog 5. Client-side JS (`src/scripts/redirect-analytics.ts`) fires analytics then redirects diff --git a/src/data/promoRedirects.ts b/src/data/promoRedirects.ts index a4c145dd..70b474d4 100644 --- a/src/data/promoRedirects.ts +++ b/src/data/promoRedirects.ts @@ -10,7 +10,7 @@ */ import { getCollection } from 'astro:content'; -import type { Redirect } from './redirects'; +import { getRedirect, type Redirect } from './redirects'; import { getTodayPacific, getPacificDateComponents, createPacificEndOfDay } from '../utils/date-helpers'; /** @@ -167,3 +167,22 @@ export async function getPromoRedirect(path: string): Promise { return null; } + +/** + * Resolve a short path to its configured redirect: promo redirects (campaigns/coupons) + * first, then static redirects. Used by both the middleware and the /redirect + * interstitial so the two always agree on where a short link goes. + */ +export async function resolveRedirect(path: string): Promise { + try { + const promo = await getPromoRedirect(path); + if (promo) return promo; + } catch (error) { + // If promo redirects fail (e.g., content collections not available), fall through + if (import.meta.env.DEV) { + console.warn('[Redirect] Could not check promo redirects:', error); + } + } + + return getRedirect(path); +} diff --git a/src/middleware.ts b/src/middleware.ts index 54bb2fb8..f404b5c6 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,5 @@ import { defineMiddleware } from 'astro:middleware'; -import { getRedirect } from './data/redirects.js'; -import { getPromoRedirect } from './data/promoRedirects.js'; +import { resolveRedirect } from './data/promoRedirects.js'; /** * Middleware to handle link shortener redirects @@ -35,57 +34,19 @@ export const onRequest = defineMiddleware(async (context, next) => { console.log('[Redirect Middleware] Checking path:', pathname); } - // Check promo redirects first (from campaigns/coupons) - let redirect = null; - try { - redirect = await getPromoRedirect(pathname); - if (redirect && import.meta.env.DEV) { - console.log('[Redirect Middleware] Found promo redirect:', pathname, '->', redirect.destination); - } - } catch (error) { - // If promo redirects fail (e.g., content collections not available), fall through - if (import.meta.env.DEV) { - console.warn('[Redirect Middleware] Could not check promo redirects:', error); - } - } - - // Fall back to static redirects if no promo redirect found - if (!redirect) { - redirect = getRedirect(pathname); - } + // Check promo redirects (from campaigns/coupons), then static redirects + const redirect = await resolveRedirect(pathname); if (redirect) { if (import.meta.env.DEV) { console.log('[Redirect Middleware] Found redirect:', pathname, '->', redirect.destination); } - // Build destination URL - let destination = redirect.destination; - - // If destination is relative, preserve query params from original request - if (!destination.startsWith('http://') && !destination.startsWith('https://')) { - const searchParams = context.url.searchParams.toString(); - if (searchParams) { - // Check if destination already has query params - const separator = destination.includes('?') ? '&' : '?'; - destination = destination + separator + searchParams; - } - } - - // Get referrer from request headers - const referrer = context.request.headers.get('referer') || ''; - - // Build redirect interstitial URL with analytics parameters + // Build redirect interstitial URL. Only the source path is passed; the + // interstitial resolves destination/type/category from the redirect config itself. // Use trailing slash to match Astro's default behavior const redirectUrl = new URL('/redirect/', context.url.origin); redirectUrl.searchParams.set('source', pathname); - redirectUrl.searchParams.set('dest', destination); - redirectUrl.searchParams.set('type', redirect.permanent ? 'permanent' : 'temporary'); - - // Add redirect category if available - if (redirect.category) { - redirectUrl.searchParams.set('category', redirect.category); - } // Add hardcoded UTM parameters from redirect config (if any) // These will be merged with any user-provided UTM params diff --git a/src/pages/redirect.astro b/src/pages/redirect.astro index e335da03..d5f13fa5 100644 --- a/src/pages/redirect.astro +++ b/src/pages/redirect.astro @@ -5,12 +5,17 @@ // Must be server-rendered to access request headers export const prerender = false; -// Get redirect parameters from URL +import { resolveRedirect } from '~/data/promoRedirects'; + +// Only `source` is read from the query string. The destination, type, and category +// come from the redirect config (promo + static) so this page can't be used as an +// open redirect (or to run a `javascript:` URL) via a crafted destination. const url = Astro.url; const sourcePath = url.searchParams.get('source') || ''; -const destinationUrl = url.searchParams.get('dest') || ''; -const redirectType = (url.searchParams.get('type') === 'permanent' ? 'permanent' : 'temporary') as 'temporary' | 'permanent'; -const redirectCategory = url.searchParams.get('category') || undefined; +const redirect = await resolveRedirect(sourcePath); +const destinationUrl = redirect?.destination || ''; +const redirectType = (redirect?.permanent ? 'permanent' : 'temporary') as 'temporary' | 'permanent'; +const redirectCategory = redirect?.category; // Get referrer - handle both server-side (headers) and client-side (document.referrer) let referrer = ''; @@ -21,8 +26,8 @@ try { referrer = ''; } -// Validate required parameters -const isValid = sourcePath && destinationUrl; +// Validate required parameters (configured destinations must still be http(s)) +const isValid = Boolean(redirect) && /^(https?:\/\/|\/(?!\/))/i.test(destinationUrl); // Debug logging in development if (import.meta.env.DEV) { @@ -37,15 +42,8 @@ if (import.meta.env.DEV) { }); } -// Build full destination URL if relative -let fullDestination = destinationUrl; -if (!destinationUrl.startsWith('http://') && !destinationUrl.startsWith('https://')) { - const baseUrl = new URL(Astro.url.origin); - fullDestination = new URL(destinationUrl, baseUrl).toString(); -} - -// Parse destination URL to merge query params -const destUrl = new URL(fullDestination); +// Build full destination URL (relative destinations resolve against this site) +const destUrl = new URL(isValid ? destinationUrl : '/', Astro.url.origin); const destParams = new URLSearchParams(destUrl.search); // Preserve any additional query params from redirect URL (UTM params, etc.) @@ -59,7 +57,7 @@ url.searchParams.forEach((value, key) => { // Rebuild destination URL with merged query params destUrl.search = destParams.toString(); -fullDestination = destUrl.toString(); +const fullDestination = destUrl.toString(); // Debug logging for final destination in development if (import.meta.env.DEV) {