Security: the unsubscribe page asks for confirmation
Corporate mail link scanners could unsubscribe someone without them ever clicking. The page now asks first, and the unsubscribe endpoint limits failed attempts.
The third security release of the day, tying up the two loose ends 2.1.3 left in the newsletter unsubscribe.
Opening the unsubscribe page no longer unsubscribes
The /[locale]/unsubscribe page unsubscribed the address as soon as it was opened with a valid
link. The catch is that a person is not always the one opening it: Microsoft Safe Links, Proofpoint
and other corporate mail scanners visit every link in an email before delivering it, and browsers
sometimes prefetch links. A subscriber could drop off your list without ever clicking.
Now, with a valid link, the page asks "Unsubscribe from the X newsletter?" and shows a Confirm unsubscribe button. Only that button unsubscribes. An invalid link or a missing token still shows "Invalid link" as before. The texts come in English and Spanish.
Gmail's "Unsubscribe" button and other clients' (RFC 8058 one-click) are unchanged: they still unsubscribe directly, because that is a deliberate request from the mail provider and scanners never make it.
A limit on failed unsubscribe attempts
POST /api/newsletter/unsubscribe only had the general API limit. It now allows 10 failed
attempts per hour per IP (no token or an invalid one) and answers 429 from the eleventh on.
An unsubscribe with a valid token never counts, whether it comes from the page, your own call or Gmail's button, and it goes through even from an IP blocked by failures. The token already proves the request is legitimate, and mail providers send one-click unsubscribes from a few shared IPs: a per-IP limit on those requests would end up refusing real unsubscribes.
If you already have a project
- Copy
src/features/marketing/components/unsubscribe-content.tsx,src/features/marketing/components/unsubscribe-confirm.tsxandsrc/features/marketing/hooks/use-unsubscribe-confirm.ts, and add theunsubscribeblock ofsrc/config/locales/{en,es}/marketing.jsonto your locale files. - If you customised the unsubscribe page, keep your design but move the call to
unsubscribeContact()out of it: the page must only check the token and show the button, whichPOSTs the email and token to/api/newsletter/unsubscribe. - In
src/lib/security/rate-limit/rate-limiter.ts, add aNEWSLETTER_UNSUBSCRIBEentry toRATE_LIMITSwithmaxRequests: 10andwindowSeconds: 60 * 60. - In
src/app/api/newsletter/unsubscribe/route.ts, apply the limit only on the 400 and 403 answers: before returning them, callrateLimitMiddleware(request, RATE_LIMITS.NEWSLETTER_UNSUBSCRIBE, { prefix: 'newsletter-unsubscribe' })and return its 429 when it is notnull. Leave the valid-token path alone. - Rebuild from clean (
rm -rf .next && bun run build) before deploying.
No migrations and no new environment variables, and the unsubscribe links already sitting in inboxes
keep working: the token and the URLs are the same. With more than one instance, the limit is per
instance unless you set REDIS_URL.