diff --git a/.cursor/rules/security-audit.mdc b/.cursor/rules/security-audit.mdc index 497864c49e..8b0b119cbe 100644 --- a/.cursor/rules/security-audit.mdc +++ b/.cursor/rules/security-audit.mdc @@ -49,6 +49,20 @@ echo -e "\n6. Cron endpoints (verify they use proper secret validation):" grep -r "hasCronSecret\|hasPostCronSecret" apps/web/app/api/ | \ cut -d: -f1 | sort | uniq +echo -e "\n7. 🚨 QStash endpoints (verify they use verifySignatureAppRouter):" +echo " (These endpoints are called from QStash and should verify signatures)" +grep -r "publishToQstash\|publishToQstashQueue" apps/web/ | \ + grep -E "(url.*api/|body.*api/)" | \ + grep -o "/api/[^\"]*" | \ + sort | uniq | \ + while read endpoint; do + if ! grep -r "verifySignatureAppRouter" "apps/web/app$endpoint" > /dev/null 2>&1; then + echo " āŒ $endpoint - Missing verifySignatureAppRouter" + else + echo " āœ… $endpoint - Uses verifySignatureAppRouter" + fi + done + echo -e "\nāœ… Audit complete! Review flagged items manually." ``` @@ -97,6 +111,20 @@ grep -r "withError.*async.*request" apps/web/app/api/ | grep -v "hasCronSecret\| # Find cron endpoints (verify they have proper authentication) grep -r "hasCronSecret\|hasPostCronSecret" apps/web/app/api/ +# 🚨 CRITICAL: Find QStash endpoints without signature verification +echo "QStash endpoints that should use verifySignatureAppRouter:" +grep -r "publishToQstash\|publishToQstashQueue" apps/web/ | \ + grep -E "(url.*api/|body.*api/)" | \ + grep -o "/api/[^\"]*" | \ + sort | uniq | \ + while read endpoint; do + if ! grep -r "verifySignatureAppRouter" "apps/web/app$endpoint" > /dev/null 2>&1; then + echo "āŒ $endpoint - Missing verifySignatureAppRouter" + else + echo "āœ… $endpoint - Uses verifySignatureAppRouter" + fi + done + # Check for weak cron secrets (should not exist) grep -r "secret.*=.*[\"'].*[\"']" apps/web/app/api/ | grep -v "CRON_SECRET" ``` @@ -142,6 +170,29 @@ if (!id || typeof id !== 'string') { } ``` +### 4. QStash Endpoint Security +```typescript +// āŒ BAD: QStash endpoint without signature verification +export const POST = withError(async (request: NextRequest) => { + const json = await request.json(); + // No signature verification - vulnerable to spoofing +}); + +// āœ… GOOD: QStash endpoint with signature verification +export const POST = withError( + verifySignatureAppRouter(async (request: NextRequest) => { + const json = await request.json(); + // Signature verified - secure from spoofing + }), +); +``` + +**QStash endpoints that MUST use `verifySignatureAppRouter`:** +- `/api/ai/digest` - Called from digest queue +- `/api/resend/digest` - Called from digest email queue +- `/api/clean/gmail` - Called from cleanup queue +- `/api/user/categorize/senders/batch` - Called from categorization queue + ## Security Review Process ### Before Code Review @@ -154,12 +205,20 @@ if (!id || typeof id !== 'string') { 2. Verify database queries include user scoping 3. Look for potential IDOR vulnerabilities 4. Check error handling for information disclosure +5. **🚨 CRITICAL: Verify QStash endpoints use `verifySignatureAppRouter`** + - Any endpoint called via `publishToQstash` or `publishToQstashQueue` + - Must wrap the handler with `verifySignatureAppRouter` + - Prevents request spoofing and ensures authenticity ### Regular Security Audits 1. Run audit script weekly 2. Review any new withError usage 3. Check for new parameter handling patterns 4. Monitor for security-related dependencies +5. **🚨 CRITICAL: Audit QStash endpoint security** + - Verify all QStash-called endpoints use `verifySignatureAppRouter` + - Check for new endpoints added to QStash queues + - Ensure signature verification is properly implemented ## Integration with CI/CD diff --git a/apps/web/app/api/resend/digest/all/route.ts b/apps/web/app/api/resend/digest/all/route.ts index e7ac503b0d..1d67e7dc5e 100644 --- a/apps/web/app/api/resend/digest/all/route.ts +++ b/apps/web/app/api/resend/digest/all/route.ts @@ -62,7 +62,7 @@ async function sendDigestAllUpdate() { queueName: "email-digest-all", parallelism: 3, // Allow up to 3 concurrent jobs from this queue url, - body: { emailAccountId: emailAccount.id, CRON_SECRET: env.CRON_SECRET }, + body: { emailAccountId: emailAccount.id }, }); } catch (error) { logger.error("Failed to publish to Qstash", { diff --git a/apps/web/app/api/resend/digest/route.ts b/apps/web/app/api/resend/digest/route.ts index 815e8603e8..fb6c378b8d 100644 --- a/apps/web/app/api/resend/digest/route.ts +++ b/apps/web/app/api/resend/digest/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { sendDigestEmail } from "@inboxzero/resend"; import { withEmailAccount, withError } from "@/utils/middleware"; import { env } from "@/env"; @@ -22,6 +22,7 @@ import { extractNameFromEmail } from "../../../../utils/email"; import { RuleName } from "@/utils/rule/consts"; import { getEmailAccountWithAiAndTokens } from "@/utils/user/get"; import { getGmailClientWithRefresh } from "@/utils/gmail/client"; +import { verifySignatureAppRouter } from "@upstash/qstash/dist/nextjs"; export const maxDuration = 60; @@ -284,36 +285,32 @@ export const GET = withEmailAccount(async (request) => { return NextResponse.json(result); }); -export const POST = withError(async (request) => { - if (!hasCronSecret(request)) { - logger.error("Unauthorized cron request"); - captureException(new Error("Unauthorized cron request: resend")); - return new Response("Unauthorized", { status: 401 }); - } - - const json = await request.json(); - const { success, data, error } = sendDigestEmailBody.safeParse(json); - - if (!success) { - logger.error("Invalid request body", { error }); - return NextResponse.json( - { error: "Invalid request body" }, - { status: 400 }, - ); - } - const { emailAccountId } = data; - - logger.info("Sending digest email to user POST", { emailAccountId }); - - try { - const result = await sendEmail({ emailAccountId }); - return NextResponse.json(result); - } catch (error) { - logger.error("Error sending digest email", { error }); - captureException(error); - return NextResponse.json( - { success: false, error: "Error sending digest email" }, - { status: 500 }, - ); - } -}); +export const POST = withError( + verifySignatureAppRouter(async (request: NextRequest) => { + const json = await request.json(); + const { success, data, error } = sendDigestEmailBody.safeParse(json); + + if (!success) { + logger.error("Invalid request body", { error }); + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + const { emailAccountId } = data; + + logger.info("Sending digest email to user POST", { emailAccountId }); + + try { + const result = await sendEmail({ emailAccountId }); + return NextResponse.json(result); + } catch (error) { + logger.error("Error sending digest email", { error }); + captureException(error); + return NextResponse.json( + { success: false, error: "Error sending digest email" }, + { status: 500 }, + ); + } + }), +);