fix(redirect): stop /redirect from following arbitrary destinations - #11
Conversation
The /redirect interstitial read its destination from the `dest` query parameter and navigated to it, so any link on a template site could send visitors anywhere (open redirect), and `dest=javascript:...` ran script on the site's origin (reflected XSS). The interstitial now reads only `source` and resolves the destination, type, and category from the redirect config via a shared resolveRedirect() (promo redirects first, then static, the same order the middleware uses). Configured destinations must still be http(s) or site-relative, so a `javascript:` ctaUrl in coupon content is rejected too. Unknown sources go to `/`. The middleware no longer passes dest/type/category.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Risk: high. This change touches redirect middleware and the interstitial used for navigation, which is security-sensitive (open redirect / XSS surface), so it is above the medium approval threshold. Cursor Bugbot was not present after the first check poll; no reviewers were assigned because the repository has no other eligible reviewers besides the author. Human review is needed before merge.
Sent by Cursor Approval Agent: NST: PR Approver


Problem
src/pages/redirect.astrotook its destination from thedestquery parameter and navigated there. On every site built from this template:javascript:value fordestpassed throughnew URL()unchanged and ran on the site's origin, through bothwindow.location.hrefand the "click here" link.Fix
resolveRedirect()insrc/data/promoRedirects.ts. It checks promo redirects (campaigns/coupons) first, then static ones, the same order the middleware already used.sourceand gets destination, type, and category fromresolveRedirect(). Resolved destinations must start withhttp(s)://or be site-relative (/…, not//…), so ajavascript:ctaUrlin coupon content is rejected too. Unknown or expired sources go to/.resolveRedirect()and no longer passesdest,type, orcategory. UTM and user query params are still forwarded and merged into the destination.CLAUDE.mdredirect flow updated.Testing
Built and ran with
wrangler dev --local:/redirect/?source=x&dest=javascript:…href="javascript:…"//redirect/?source=/go&dest=https://evil.example/contact-us//redirect/?source=/go&utm_term=abc/contact-us/?utm_term=abc/redirect/?source=/wellness20&utm_term=abc(coupon, expiry temporarily extended)/contact-us/?coupon=WELLNESS20&utm_term=abcctaUrl: "javascript:…"(temporary fixture)/ny2025astro check: 0 errors.npm run check(ESLint/Prettier) already fails onmain(66 ESLint errors, 57 unformatted files), and this PR adds no new failures.Not addressed here (existing issue)
With
output: 'static', the short links themselves (/go,/wellness20) return 404 onmain, because the middleware only runs for on-demand routes. This PR doesn't change that. nightsquawk-tech works around it withoutput: 'server'.Rollout
Every client repo built from this template has the vulnerable interstitial until it merges
upstream/mainand redeploys.🤖 Generated with Claude Code