-
-
Notifications
You must be signed in to change notification settings - Fork 5
(SP: 3) [Security] Harden admin CSRF gate + add DB-backed rate limiting for checkout and Stripe webhooks #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e32fb7a
13885c2
d319208
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,9 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| enforceRateLimit, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| getClientIp, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rateLimitResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '@/lib/security/rate-limit'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getCurrentUser } from '@/lib/auth'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { isPaymentsEnabled } from '@/lib/env/stripe'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { logError, logWarn } from '@/lib/logging'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -227,6 +231,24 @@ export async function POST(request: NextRequest) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // P1: rate limit checkout (cross-instance, DB-backed) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Policy: allow reasonable retries; block abusive burst. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const decision = await enforceRateLimit({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key: `checkout:${checkoutSubject}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| limit: Number(process.env.CHECKOUT_RATE_LIMIT_MAX ?? 10), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| windowSeconds: Number( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? 300 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!decision.ok) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return rateLimitResponse({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| retryAfterSeconds: decision.retryAfterSeconds, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| details: { scope: 'checkout' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+234
to
+251
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard rate-limit env parsing to avoid NaN/zero defaults. If 🔧 Suggested fix- const decision = await enforceRateLimit({
- key: `checkout:${checkoutSubject}`,
- limit: Number(process.env.CHECKOUT_RATE_LIMIT_MAX ?? 10),
- windowSeconds: Number(
- process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? 300
- ),
- });
+ const limitRaw = Number(process.env.CHECKOUT_RATE_LIMIT_MAX);
+ const windowRaw = Number(process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS);
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10;
+ const windowSeconds =
+ Number.isFinite(windowRaw) && windowRaw > 0 ? windowRaw : 300;
+
+ const decision = await enforceRateLimit({
+ key: `checkout:${checkoutSubject}`,
+ limit,
+ windowSeconds,
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const result = await createOrderWithItems({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,11 @@ import { | |
| appendRefundToMeta, | ||
| } from '@/lib/services/orders/psp-metadata/refunds'; | ||
| import { markStripeAttemptFinal } from '@/lib/services/orders/payment-attempts'; | ||
| import { | ||
| enforceRateLimit, | ||
| getClientIp, | ||
| rateLimitResponse, | ||
| } from '@/lib/security/rate-limit'; | ||
|
|
||
| const REFUND_FULLNESS_UNDETERMINED = 'REFUND_FULLNESS_UNDETERMINED' as const; | ||
|
|
||
|
|
@@ -312,6 +317,22 @@ export async function POST(request: NextRequest) { | |
|
|
||
| const signature = request.headers.get('stripe-signature'); | ||
| if (!signature) { | ||
| const ip = getClientIp(request) ?? 'anon'; | ||
| const decision = await enforceRateLimit({ | ||
| key: `stripe_webhook:missing_sig:${ip}`, | ||
|
Comment on lines
+320
to
+322
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, find the implementation of getClientIp
find . -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" | head -20Repository: DevLoversTeam/devlovers.net Length of output: 934 🏁 Script executed: # Search for getClientIp implementation
rg "function getClientIp|const getClientIp|export.*getClientIp" --type ts --type tsx --type jsRepository: DevLoversTeam/devlovers.net Length of output: 98 🏁 Script executed: # Look at the Stripe webhook route file
wc -l frontend/app/api/shop/webhooks/stripe/route.tsRepository: DevLoversTeam/devlovers.net Length of output: 122 🏁 Script executed: # Read the specific lines mentioned in the review
sed -n '310,330p' frontend/app/api/shop/webhooks/stripe/route.tsRepository: DevLoversTeam/devlovers.net Length of output: 746 🏁 Script executed: # Also check lines 356-358
sed -n '350,365p' frontend/app/api/shop/webhooks/stripe/route.tsRepository: DevLoversTeam/devlovers.net Length of output: 551 🏁 Script executed: # Search for where enforceRateLimit and getClientIp are imported from
head -50 frontend/app/api/shop/webhooks/stripe/route.tsRepository: DevLoversTeam/devlovers.net Length of output: 1748 🏁 Script executed: # Find the rate-limit utility file
find . -path "*/lib/security/rate-limit*" -type fRepository: DevLoversTeam/devlovers.net Length of output: 108 🏁 Script executed: # Search for the rate-limit file more broadly
fd "rate-limit" --type fRepository: DevLoversTeam/devlovers.net Length of output: 106 🏁 Script executed: # Search for the lib/security directory
ls -la frontend/lib/security/ 2>/dev/null || echo "Path not found, searching..."Repository: DevLoversTeam/devlovers.net Length of output: 370 🏁 Script executed: # Search more broadly for getClientIp implementation
rg "getClientIp" --type ts --type js -A 5 -B 2Repository: DevLoversTeam/devlovers.net Length of output: 3896 🏁 Script executed: # Read the full rate-limit.ts file to see the complete getClientIp implementation
cat -n frontend/lib/security/rate-limit.tsRepository: DevLoversTeam/devlovers.net Length of output: 5200 IP-spoofing risk requires deployment boundary verification.
Action: Document your deployment boundary (e.g., "deployed behind Cloudflare" or "reverse proxy at Also applies to: 356-358 🤖 Prompt for AI Agents |
||
| limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30), | ||
| windowSeconds: Number( | ||
| process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60 | ||
| ), | ||
|
Comment on lines
+323
to
+326
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against NaN in env‑driven limits.
🛠️ Proposed fix+function readIntEnv(name: string, fallback: number) {
+ const raw = process.env[name];
+ const parsed = Number.parseInt(raw ?? '', 10);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
@@
- const decision = await enforceRateLimit({
- key: `stripe_webhook:missing_sig:${ip}`,
- limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
- windowSeconds: Number(
- process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
- ),
- });
+ const decision = await enforceRateLimit({
+ key: `stripe_webhook:missing_sig:${ip}`,
+ limit: readIntEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', 30),
+ windowSeconds: readIntEnv(
+ 'STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS',
+ 60
+ ),
+ });
@@
- const decision = await enforceRateLimit({
- key: `stripe_webhook:invalid_sig:${ip}`,
- limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
- windowSeconds: Number(
- process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
- ),
- });
+ const decision = await enforceRateLimit({
+ key: `stripe_webhook:invalid_sig:${ip}`,
+ limit: readIntEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', 30),
+ windowSeconds: readIntEnv(
+ 'STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS',
+ 60
+ ),
+ });Also applies to: 359-362 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| if (!decision.ok) { | ||
| return rateLimitResponse({ | ||
| retryAfterSeconds: decision.retryAfterSeconds, | ||
| details: { scope: 'stripe_webhook', reason: 'missing_signature' }, | ||
| }); | ||
| } | ||
|
|
||
| logError( | ||
| 'Stripe webhook missing signature header', | ||
| new Error('MISSING_STRIPE_SIGNATURE') | ||
|
|
@@ -332,6 +353,22 @@ export async function POST(request: NextRequest) { | |
| error instanceof Error && | ||
| error.message === 'STRIPE_INVALID_SIGNATURE' | ||
| ) { | ||
| const ip = getClientIp(request) ?? 'anon'; | ||
| const decision = await enforceRateLimit({ | ||
| key: `stripe_webhook:invalid_sig:${ip}`, | ||
| limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30), | ||
| windowSeconds: Number( | ||
| process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60 | ||
| ), | ||
| }); | ||
|
|
||
| if (!decision.ok) { | ||
| return rateLimitResponse({ | ||
| retryAfterSeconds: decision.retryAfterSeconds, | ||
| details: { scope: 'stripe_webhook', reason: 'invalid_signature' }, | ||
| }); | ||
| } | ||
|
|
||
| logError('Stripe webhook signature verification failed', error); | ||
| return NextResponse.json({ code: 'INVALID_SIGNATURE' }, { status: 400 }); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The refund endpoint now hard-requires
requireAdminCsrf, but the only caller (frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx) still posts withoutx-csrf-tokenorcsrfTokenform field. That means every admin refund will now return403 CSRF_MISSINGeven for valid admins, effectively breaking refunds in the UI. Either pass an issued token intoRefundButtonand set the header, or allow this specific endpoint to accept the existing request format.Useful? React with 👍 / 👎.