Skip to content
Merged
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
59 changes: 59 additions & 0 deletions .cursor/rules/security-audit.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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."
```

Expand Down Expand Up @@ -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"
```
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/resend/digest/all/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
65 changes: 31 additions & 34 deletions apps/web/app/api/resend/digest/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -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 },
);
}
}),
);
Loading