Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion app/api/newsletter/unsubscribe/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion app/unsubscribe/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -22,7 +23,7 @@ function UnsubscribeContent() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId }),
body: JSON.stringify({ userId, token }),
});

if (response.ok) {
Expand Down
35 changes: 35 additions & 0 deletions lib/newsletter-security.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 3 additions & 1 deletion scripts/send-newsletter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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();

Expand Down