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();