From 145749581851a2f701bdb2fbff7bc5950d39a219 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:06:23 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20IDOR=20in=20Newsletter=20Unsubscribe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL 💡 Vulnerability: Insecure Direct Object Reference (IDOR) in `app/api/newsletter/unsubscribe/route.ts` allowed any user to unsubscribe any other user by knowing their `userId`. 🎯 Impact: Unauthorized modification of user preferences (email subscription status). 🔧 Fix: 1. Introduced `lib/newsletter-security.ts` to generate and verify HMAC-SHA256 tokens signed with `CSRF_SALT`. 2. Updated `scripts/send-newsletter.ts` to append a signed `token` to unsubscribe links. 3. Updated `app/unsubscribe/page.tsx` to read the token and pass it to the API. 4. Updated `app/api/newsletter/unsubscribe/route.ts` to verify the token before allowing the action. ✅ Verification: Confirmed via manual inspection and local simulation that the API now rejects requests without a valid token. --- .jules/sentinel.md | 4 +++ app/api/newsletter/unsubscribe/route.ts | 11 +++++++- app/unsubscribe/page.tsx | 3 ++- lib/newsletter-security.ts | 35 +++++++++++++++++++++++++ scripts/send-newsletter.ts | 4 ++- 5 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 .jules/sentinel.md create mode 100644 lib/newsletter-security.ts diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..a75b462 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2024-05-23 - [IDOR in Newsletter Unsubscribe] +**Vulnerability:** The unsubscribe endpoint `/api/newsletter/unsubscribe` accepted a JSON payload with `userId` and directly unsubscribed that user without any authentication or signature verification. +**Learning:** Manual scripts that generate public links (like email newsletters) often skip standard security practices (like authentication) because they are "internal tools", but the resulting links expose public endpoints. +**Prevention:** Always require a cryptographic signature (HMAC) for any action performed via a public link that acts on a specific user's data without a session. diff --git a/app/api/newsletter/unsubscribe/route.ts b/app/api/newsletter/unsubscribe/route.ts index 2280c61..c3d83b6 100644 --- a/app/api/newsletter/unsubscribe/route.ts +++ b/app/api/newsletter/unsubscribe/route.ts @@ -1,9 +1,10 @@ import { createServiceRoleClient } from '../../../../lib/supabase/admin'; import { NextResponse } from 'next/server'; +import { verifyUnsubscribeToken } from '@/lib/newsletter-security'; export async function POST(request: Request) { try { - const { userId } = await request.json(); + const { userId, token } = await request.json(); if (!userId) { return NextResponse.json( @@ -12,6 +13,14 @@ export async function POST(request: Request) { ); } + // Validate token to prevent unauthorized unsubscriptions + if (!token || !verifyUnsubscribeToken(userId, token)) { + return NextResponse.json( + { error: 'Invalid or missing unsubscribe token' }, + { status: 403 } + ); + } + // Force casting to any to bypass the restrictive "never" inference on the Supabase client // which likely stems from a mismatch in generated types vs actual usage in this context. const supabase = createServiceRoleClient() as any; diff --git a/app/unsubscribe/page.tsx b/app/unsubscribe/page.tsx index 033fe85..506de89 100644 --- a/app/unsubscribe/page.tsx +++ b/app/unsubscribe/page.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; function UnsubscribeContent() { const searchParams = useSearchParams(); const userId = searchParams.get('uid'); + const token = searchParams.get('token'); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); useEffect(() => { @@ -22,7 +23,7 @@ function UnsubscribeContent() { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ userId }), + body: JSON.stringify({ userId, token }), }); if (response.ok) { diff --git a/lib/newsletter-security.ts b/lib/newsletter-security.ts new file mode 100644 index 0000000..c956382 --- /dev/null +++ b/lib/newsletter-security.ts @@ -0,0 +1,35 @@ +import crypto from 'crypto'; + +const SECRET_KEY = process.env.CSRF_SALT || process.env.SUPABASE_SERVICE_ROLE_KEY || 'default-secret-key-change-me'; + +/** + * Generates a signed token for unsubscribing a user. + * The token is an HMAC-SHA256 of the userId. + */ +export function generateUnsubscribeToken(userId: string): string { + if (!userId) throw new Error('userId is required'); + + const hmac = crypto.createHmac('sha256', SECRET_KEY); + hmac.update(userId); + return hmac.digest('hex'); +} + +/** + * Verifies if the token is valid for the given userId. + */ +export function verifyUnsubscribeToken(userId: string, token: string): boolean { + if (!userId || !token) return false; + + const expectedToken = generateUnsubscribeToken(userId); + + // Use constant-time comparison to prevent timing attacks + try { + return crypto.timingSafeEqual( + Buffer.from(token), + Buffer.from(expectedToken) + ); + } catch (e) { + // Length mismatch or other error + return false; + } +} diff --git a/scripts/send-newsletter.ts b/scripts/send-newsletter.ts index bba75a7..8c3fd6f 100644 --- a/scripts/send-newsletter.ts +++ b/scripts/send-newsletter.ts @@ -2,6 +2,7 @@ import * as dotenv from 'dotenv'; import * as postmark from 'postmark'; import { createServiceRoleClient } from '../lib/supabase/admin'; import { getHtmlBody, getSubject } from '../lib/email/templates/monthly-update'; +import { generateUnsubscribeToken } from '../lib/newsletter-security'; // Load environment variables from .env.local dotenv.config({ path: '.env.local' }); @@ -79,7 +80,8 @@ async function sendNewsletter() { for (const profile of profiles) { if (!profile.email) continue; - const unsubscribeUrl = `${NEXT_PUBLIC_APP_URL}/unsubscribe?uid=${profile.id}`; + const token = generateUnsubscribeToken(profile.id); + const unsubscribeUrl = `${NEXT_PUBLIC_APP_URL}/unsubscribe?uid=${profile.id}&token=${token}`; const htmlBody = getHtmlBody(unsubscribeUrl); const subject = getSubject();