From 80a23c3d968a43b301aa3e4acd3416da995c99da Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:04:50 -0500 Subject: [PATCH 1/2] hardening: G5 add provider-safe rate limiting --- .env.example | 8 ++ README.md | 2 + app/api/stripe/webhook/route.ts | 33 +++++++ app/api/twilio/sms/route.ts | 53 +++++++++++- app/api/twilio/status/route.ts | 53 +++++++++++- app/api/twilio/voice/route.ts | 59 ++++++++++++- docs/PRODUCTION_ENV.md | 7 ++ docs/PRODUCTION_READINESS_GAPS.md | 44 ++++++++++ lib/rate-limit-config.ts | 31 +++++++ lib/rate-limit.ts | 138 ++++++++++++++++++++++++++++++ middleware.ts | 21 +++++ tests/rate-limit.test.ts | 66 ++++++++++++++ 12 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 lib/rate-limit-config.ts create mode 100644 lib/rate-limit.ts create mode 100644 tests/rate-limit.test.ts diff --git a/.env.example b/.env.example index fb5f130..be5086f 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,14 @@ TWILIO_VALIDATE_SIGNATURE= DEBUG_ENV_ENDPOINT_TOKEN= PORTFOLIO_DEMO_MODE= +# Optional rate limiting (defaults are safe for provider webhooks) +# RATE_LIMIT_WINDOW_MS=60000 +# RATE_LIMIT_TWILIO_AUTH_MAX=240 +# RATE_LIMIT_TWILIO_UNAUTH_MAX=40 +# RATE_LIMIT_STRIPE_AUTH_MAX=240 +# RATE_LIMIT_STRIPE_UNAUTH_MAX=40 +# RATE_LIMIT_PROTECTED_API_MAX=80 + # Vercel system envs (auto-set on Vercel; optional locally for fallback testing only) # VERCEL_ENV= # VERCEL_URL= diff --git a/README.md b/README.md index 445d64b..80ec288 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Required categories: - Stripe keys + price IDs + webhook secret - Twilio credentials + webhook auth token - Database URL +- Optional rate-limit tuning vars (defaults are built in) ### 4. Run Prisma migrations / generate client @@ -249,6 +250,7 @@ Compliance handling: Security / idempotency notes: - Invalid webhook token -> `401` +- Unauthorized webhook bursts are rate-limited with `429` (`Retry-After` + `X-RateLimit-*` headers) - Duplicate inbound SMS retries with the same `MessageSid` are deduped via `Message.twilioSid` and ignored after persistence check - Webhook handlers log structured events (`callSid` / `messageSid`, event type, decision) diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 8bc19f2..e0922d6 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'; import Stripe from 'stripe'; import { db } from '@/lib/db'; +import { RATE_LIMIT_STRIPE_AUTH_MAX, RATE_LIMIT_STRIPE_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { getStripe } from '@/lib/stripe'; export const runtime = 'nodejs'; @@ -82,6 +84,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { } export async function POST(request: Request) { + const clientIp = getClientIpAddress(request); const signature = request.headers.get('stripe-signature'); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!signature || !webhookSecret) { @@ -95,10 +98,40 @@ export async function POST(request: Request) { try { event = stripe.webhooks.constructEvent(payload, signature, webhookSecret); } catch (error) { + const unauthRateLimit = consumeRateLimit({ + key: `stripe:webhook:unauth:${clientIp}`, + limit: RATE_LIMIT_STRIPE_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!unauthRateLimit.allowed) { + console.warn('Stripe webhook rate-limited (invalid signature burst)', { + clientIp, + decision: 'reject_429', + }); + return NextResponse.json( + { error: 'Too many invalid webhook attempts' }, + { status: 429, headers: buildRateLimitHeaders(unauthRateLimit) } + ); + } + const message = error instanceof Error ? error.message : 'Invalid webhook signature'; return NextResponse.json({ error: message }, { status: 400 }); } + const authRateLimit = consumeRateLimit({ + key: `stripe:webhook:auth:${clientIp}`, + limit: RATE_LIMIT_STRIPE_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!authRateLimit.allowed) { + console.warn('Stripe webhook rate-limited', { + clientIp, + eventType: event.type, + decision: 'reject_429', + }); + return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429, headers: buildRateLimitHeaders(authRateLimit) }); + } + try { switch (event.type) { case 'checkout.session.completed': diff --git a/app/api/twilio/sms/route.ts b/app/api/twilio/sms/route.ts index facc083..6393158 100644 --- a/app/api/twilio/sms/route.ts +++ b/app/api/twilio/sms/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; import { normalizePhoneNumber } from '@/lib/phone'; +import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { advanceLeadConversation } from '@/lib/sms-state-machine'; import { isSubscriptionActive } from '@/lib/subscription'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; @@ -35,17 +37,66 @@ function retryableErrorResponse() { return buildTwilioRetryableErrorResponse('sms'); } +function rateLimitSmsResponse(retryAfterSeconds: number) { + return new NextResponse(messagingTwiML(), { + status: 429, + headers: { + 'Content-Type': 'text/xml', + 'Retry-After': String(retryAfterSeconds), + }, + }); +} + export async function POST(request: Request) { let messageSid: string | null = null; try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; + const clientIp = getClientIpAddress(request); + const accountSid = formField(formData, 'AccountSid'); + + const authorized = hasValidTwilioWebhookRequest(request, payload); + if (!authorized) { + const rateLimit = consumeRateLimit({ + key: `twilio:sms:unauth:${clientIp}`, + limit: RATE_LIMIT_TWILIO_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('sms', 'webhook_unauthorized_rate_limited', { + eventType: 'inbound_sms', + decision: 'reject_429', + clientIp, + }); + return new NextResponse( + JSON.stringify({ error: 'Too many unauthorized requests' }), + { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } + ); + } - if (!hasValidTwilioWebhookRequest(request, payload)) { logTwilioWarn('sms', 'webhook_unauthorized', { decision: 'reject_401' }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const authRateLimit = consumeRateLimit({ + key: `twilio:sms:auth:${accountSid || clientIp}`, + limit: RATE_LIMIT_TWILIO_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!authRateLimit.allowed) { + logTwilioWarn('sms', 'webhook_rate_limited', { + eventType: 'inbound_sms', + decision: 'reject_429', + accountSid: accountSid || null, + clientIp, + }); + const response = rateLimitSmsResponse(authRateLimit.retryAfterSeconds); + Object.entries(buildRateLimitHeaders(authRateLimit)).forEach(([name, value]) => { + response.headers.set(name, value); + }); + return response; + } + const to = normalizePhoneNumber(formField(formData, 'To')); const from = normalizePhoneNumber(formField(formData, 'From')); const body = formField(formData, 'Body'); diff --git a/app/api/twilio/status/route.ts b/app/api/twilio/status/route.ts index 8d6d113..13c017f 100644 --- a/app/api/twilio/status/route.ts +++ b/app/api/twilio/status/route.ts @@ -4,6 +4,8 @@ import { SubscriptionStatus } from '@prisma/client'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; import { normalizePhoneNumber } from '@/lib/phone'; +import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { getServicePrompt } from '@/lib/sms-state-machine'; import { isSubscriptionActive } from '@/lib/subscription'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; @@ -41,18 +43,67 @@ function retryableErrorResponse() { return buildTwilioRetryableErrorResponse('status'); } +function rateLimitStatusResponse(retryAfterSeconds: number) { + return new NextResponse(messagingTwiML(), { + status: 429, + headers: { + 'Content-Type': 'text/xml', + 'Retry-After': String(retryAfterSeconds), + }, + }); +} + export async function POST(request: Request) { let callSid: string | null = null; let dialCallSid: string | null = null; try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; + const clientIp = getClientIpAddress(request); + const accountSid = formField(formData, 'AccountSid'); + + const authorized = hasValidTwilioWebhookRequest(request, payload); + if (!authorized) { + const rateLimit = consumeRateLimit({ + key: `twilio:status:unauth:${clientIp}`, + limit: RATE_LIMIT_TWILIO_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('status', 'webhook_unauthorized_rate_limited', { + eventType: 'dial_status_callback', + decision: 'reject_429', + clientIp, + }); + return new NextResponse( + JSON.stringify({ error: 'Too many unauthorized requests' }), + { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } + ); + } - if (!hasValidTwilioWebhookRequest(request, payload)) { logTwilioWarn('status', 'webhook_unauthorized', { decision: 'reject_401' }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const authRateLimit = consumeRateLimit({ + key: `twilio:status:auth:${accountSid || clientIp}`, + limit: RATE_LIMIT_TWILIO_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!authRateLimit.allowed) { + logTwilioWarn('status', 'webhook_rate_limited', { + eventType: 'dial_status_callback', + decision: 'reject_429', + accountSid: accountSid || null, + clientIp, + }); + const response = rateLimitStatusResponse(authRateLimit.retryAfterSeconds); + Object.entries(buildRateLimitHeaders(authRateLimit)).forEach(([name, value]) => { + response.headers.set(name, value); + }); + return response; + } + const to = normalizePhoneNumber(formField(formData, 'To')); const from = normalizePhoneNumber(formField(formData, 'From')); callSid = formField(formData, 'CallSid') || null; diff --git a/app/api/twilio/voice/route.ts b/app/api/twilio/voice/route.ts index 93989bc..005dd0b 100644 --- a/app/api/twilio/voice/route.ts +++ b/app/api/twilio/voice/route.ts @@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; import { normalizePhoneNumber } from '@/lib/phone'; +import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; import { buildDialRecordingOptions } from '@/lib/twilio-recording'; import { hasValidTwilioWebhookRequest } from '@/lib/twilio-webhook'; @@ -25,13 +27,47 @@ function withWebhookToken(url: string) { return next.toString(); } +function rateLimitVoiceResponse(retryAfterSeconds: number) { + const xml = voiceTwiML((response) => { + response.say('Too many requests. Please try again shortly.'); + response.hangup(); + }); + return new NextResponse(xml, { + status: 429, + headers: { + 'Content-Type': 'text/xml', + 'Retry-After': String(retryAfterSeconds), + }, + }); +} + export async function POST(request: Request) { let callSid: string | null = null; try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; + const clientIp = getClientIpAddress(request); + + const authorized = hasValidTwilioWebhookRequest(request, payload); + if (!authorized) { + const rateLimit = consumeRateLimit({ + key: `twilio:voice:unauth:${clientIp}`, + limit: RATE_LIMIT_TWILIO_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('voice', 'webhook_unauthorized_rate_limited', { + callSid, + eventType: 'incoming_call', + decision: 'reject_429', + clientIp, + }); + return new NextResponse( + JSON.stringify({ error: 'Too many unauthorized requests' }), + { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } + ); + } - if (!hasValidTwilioWebhookRequest(request, payload)) { logTwilioWarn('voice', 'webhook_unauthorized', { decision: 'reject_401' }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -39,6 +75,27 @@ export async function POST(request: Request) { const to = normalizePhoneNumber(formField(formData, 'To')); const from = normalizePhoneNumber(formField(formData, 'From')); callSid = formField(formData, 'CallSid') || null; + const accountSid = formField(formData, 'AccountSid'); + + const rateLimit = consumeRateLimit({ + key: `twilio:voice:auth:${accountSid || clientIp}`, + limit: RATE_LIMIT_TWILIO_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('voice', 'webhook_rate_limited', { + callSid, + eventType: 'incoming_call', + decision: 'reject_429', + accountSid: accountSid || null, + clientIp, + }); + const response = rateLimitVoiceResponse(rateLimit.retryAfterSeconds); + Object.entries(buildRateLimitHeaders(rateLimit)).forEach(([name, value]) => { + response.headers.set(name, value); + }); + return response; + } logTwilioInfo('voice', 'webhook_received', { callSid, diff --git a/docs/PRODUCTION_ENV.md b/docs/PRODUCTION_ENV.md index 1459840..3cf8d81 100644 --- a/docs/PRODUCTION_ENV.md +++ b/docs/PRODUCTION_ENV.md @@ -30,6 +30,12 @@ This project uses `NEXT_PUBLIC_APP_URL` as the single canonical app origin for s | `TWILIO_VALIDATE_SIGNATURE` | Server-only | Yes (production) | Vercel | Must be `true` in production. Twilio webhooks require valid `X-Twilio-Signature` verification using `TWILIO_AUTH_TOKEN`; production fails closed otherwise. | | `DEBUG_ENV_ENDPOINT_TOKEN` | Server-only | Optional | Vercel | Protects `/api/debug/env` in production. If unset, the endpoint returns `404` in production. | | `PORTFOLIO_DEMO_MODE` | Server-only | Optional | Local / Vercel | Enables demo data/auth bypass mode for portfolio/demo screenshots. Keep disabled in production unless intentionally using demo mode. | +| `RATE_LIMIT_WINDOW_MS` | Server-only | Optional | Vercel | Shared rate-limit window in milliseconds. Default `60000`. | +| `RATE_LIMIT_TWILIO_AUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for valid/authorized traffic. Default `240`. | +| `RATE_LIMIT_TWILIO_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for unauthorized traffic. Default `40`. | +| `RATE_LIMIT_STRIPE_AUTH_MAX` | Server-only | Optional | Vercel | Max Stripe webhook requests per window for valid-signed traffic. Default `240`. | +| `RATE_LIMIT_STRIPE_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Stripe webhook requests per window for invalid-signature traffic. Default `40`. | +| `RATE_LIMIT_PROTECTED_API_MAX` | Server-only | Optional | Vercel | Max requests per window for protected Stripe mutation APIs (`/api/stripe/checkout`, `/api/stripe/portal`). Default `80`. | ## Runtime Validation (Production) @@ -44,6 +50,7 @@ The app now validates required server env vars at runtime in production via `lib - Twilio webhook auth behavior: - Production: `TWILIO_VALIDATE_SIGNATURE=true` is required and token-only auth is rejected - Non-production: signature mode can fall back to shared-token auth for local/dev workflows +- Rate limiting defaults are tuned to avoid blocking normal Twilio/Stripe provider traffic while still throttling abusive bursts. Tune limits only if you observe false positives in logs. - `NEXT_PUBLIC_APP_URL` is the canonical value and should be set explicitly. If it is missing/invalid, the app can temporarily fall back to Vercel system env vars (`VERCEL_URL` / `VERCEL_PROJECT_PRODUCTION_URL`) to avoid auth-page crashes, but webhook/redirect behavior should still use an explicit `NEXT_PUBLIC_APP_URL`. ## Vercel: Preview vs Production diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index bc6f5db..3b56757 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -253,3 +253,47 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `6532134` + +- 2026-03-02 - G5 (DONE) + - Branch: `hardening/g5-rate-limiting` + - What changed: + - Added shared in-memory limiter utilities: + - `lib/rate-limit.ts` (bucket store, client IP extraction, rate-limit headers) + - `lib/rate-limit-config.ts` (env-tunable defaults) + - Added auth-aware webhook throttling: + - Twilio `voice/status/sms` routes now enforce: + - stricter unauthenticated burst limits + - higher limits for authorized provider traffic + - Stripe webhook now enforces: + - stricter invalid-signature burst limits + - higher limits for valid-signed webhook traffic + - Added middleware throttling for protected Stripe mutation APIs: + - `/api/stripe/checkout` + - `/api/stripe/portal` + - Added rate-limit unit tests in `tests/rate-limit.test.ts`. + - Documented optional rate-limit env knobs in `.env.example`, `README.md`, and `docs/PRODUCTION_ENV.md`. + - Safety notes: + - Twilio/Stripe normal traffic is protected by separate authorized-vs-unauthorized thresholds to reduce false positives. + - All 429 responses include `Retry-After` and `X-RateLimit-*` headers. + - Commands run + results: + - `npm test` -> PASS (26/26) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `lib/rate-limit.ts` + - `lib/rate-limit-config.ts` + - `middleware.ts` + - `app/api/twilio/voice/route.ts` + - `app/api/twilio/status/route.ts` + - `app/api/twilio/sms/route.ts` + - `app/api/stripe/webhook/route.ts` + - `tests/rate-limit.test.ts` + - `.env.example` + - `README.md` + - `docs/PRODUCTION_ENV.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `PENDING` diff --git a/lib/rate-limit-config.ts b/lib/rate-limit-config.ts new file mode 100644 index 0000000..0692146 --- /dev/null +++ b/lib/rate-limit-config.ts @@ -0,0 +1,31 @@ +import { getRateLimitNumber } from './rate-limit'; + +export const RATE_LIMIT_WINDOW_MS = getRateLimitNumber('RATE_LIMIT_WINDOW_MS', 60_000, { + min: 1_000, + max: 3_600_000, +}); + +export const RATE_LIMIT_TWILIO_AUTH_MAX = getRateLimitNumber('RATE_LIMIT_TWILIO_AUTH_MAX', 240, { + min: 10, + max: 10_000, +}); + +export const RATE_LIMIT_TWILIO_UNAUTH_MAX = getRateLimitNumber('RATE_LIMIT_TWILIO_UNAUTH_MAX', 40, { + min: 5, + max: 5_000, +}); + +export const RATE_LIMIT_STRIPE_AUTH_MAX = getRateLimitNumber('RATE_LIMIT_STRIPE_AUTH_MAX', 240, { + min: 10, + max: 10_000, +}); + +export const RATE_LIMIT_STRIPE_UNAUTH_MAX = getRateLimitNumber('RATE_LIMIT_STRIPE_UNAUTH_MAX', 40, { + min: 5, + max: 5_000, +}); + +export const RATE_LIMIT_PROTECTED_API_MAX = getRateLimitNumber('RATE_LIMIT_PROTECTED_API_MAX', 80, { + min: 10, + max: 10_000, +}); diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..4b19545 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,138 @@ +type RateLimitBucket = { + count: number; + resetAtMs: number; +}; + +export type RateLimitDecision = { + allowed: boolean; + limit: number; + remaining: number; + resetAtMs: number; + retryAfterSeconds: number; +}; + +const bucketStore = new Map(); +let consumeCount = 0; + +function clampInteger(value: number, min: number, max: number) { + const next = Math.trunc(value); + if (!Number.isFinite(next)) return min; + if (next < min) return min; + if (next > max) return max; + return next; +} + +function pruneExpiredBuckets(nowMs: number) { + consumeCount += 1; + if (consumeCount % 200 !== 0) return; + + for (const [key, bucket] of bucketStore.entries()) { + if (bucket.resetAtMs <= nowMs) { + bucketStore.delete(key); + } + } +} + +export function consumeRateLimit(input: { key: string; limit: number; windowMs: number; nowMs?: number }): RateLimitDecision { + const nowMs = input.nowMs ?? Date.now(); + const limit = clampInteger(input.limit, 1, 1_000_000); + const windowMs = clampInteger(input.windowMs, 1_000, 3_600_000); + const key = input.key.trim(); + + if (!key) { + return { + allowed: true, + limit, + remaining: limit, + resetAtMs: nowMs + windowMs, + retryAfterSeconds: 0, + }; + } + + const existing = bucketStore.get(key); + if (!existing || existing.resetAtMs <= nowMs) { + const next: RateLimitBucket = { count: 1, resetAtMs: nowMs + windowMs }; + bucketStore.set(key, next); + pruneExpiredBuckets(nowMs); + return { + allowed: true, + limit, + remaining: Math.max(limit - 1, 0), + resetAtMs: next.resetAtMs, + retryAfterSeconds: 0, + }; + } + + if (existing.count >= limit) { + const retryAfterMs = Math.max(existing.resetAtMs - nowMs, 0); + return { + allowed: false, + limit, + remaining: 0, + resetAtMs: existing.resetAtMs, + retryAfterSeconds: Math.ceil(retryAfterMs / 1000), + }; + } + + existing.count += 1; + bucketStore.set(key, existing); + + return { + allowed: true, + limit, + remaining: Math.max(limit - existing.count, 0), + resetAtMs: existing.resetAtMs, + retryAfterSeconds: 0, + }; +} + +export function getClientIpAddress(request: Pick) { + const headers = request.headers; + const forwardedFor = headers.get('x-forwarded-for')?.trim(); + if (forwardedFor) { + const first = forwardedFor.split(',')[0]?.trim(); + if (first) return first; + } + + const realIp = headers.get('x-real-ip')?.trim(); + if (realIp) return realIp; + + const cloudflareIp = headers.get('cf-connecting-ip')?.trim(); + if (cloudflareIp) return cloudflareIp; + + return 'unknown'; +} + +export function getRateLimitNumber( + envName: string, + fallback: number, + options: { + min?: number; + max?: number; + env?: Record; + } = {} +) { + const env = options.env ?? process.env; + const min = options.min ?? 1; + const max = options.max ?? 1_000_000; + const raw = env[envName]?.trim(); + if (!raw) return clampInteger(fallback, min, max); + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return clampInteger(fallback, min, max); + return clampInteger(parsed, min, max); +} + +export function buildRateLimitHeaders(result: RateLimitDecision) { + return { + 'Retry-After': String(result.retryAfterSeconds), + 'X-RateLimit-Limit': String(result.limit), + 'X-RateLimit-Remaining': String(result.remaining), + 'X-RateLimit-Reset': String(Math.ceil(result.resetAtMs / 1000)), + }; +} + +export function resetRateLimitStore() { + bucketStore.clear(); + consumeCount = 0; +} diff --git a/middleware.ts b/middleware.ts index 05054ef..4ffa092 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,11 @@ +import { NextResponse } from 'next/server'; import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; +import { RATE_LIMIT_PROTECTED_API_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; + const isProtectedRoute = createRouteMatcher(['/app(.*)', '/api/stripe/checkout(.*)', '/api/stripe/portal(.*)']); +const isProtectedApiMutationRoute = createRouteMatcher(['/api/stripe/checkout', '/api/stripe/portal']); export default clerkMiddleware(async (auth, req) => { if (process.env.PORTFOLIO_DEMO_MODE === '1') { @@ -10,6 +15,22 @@ export default clerkMiddleware(async (auth, req) => { if (isProtectedRoute(req)) { await auth.protect(); } + + if (req.method === 'POST' && isProtectedApiMutationRoute(req)) { + const clientIp = getClientIpAddress(req); + const rateLimit = consumeRateLimit({ + key: `middleware:protected-api:${clientIp}`, + limit: RATE_LIMIT_PROTECTED_API_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many requests' }, + { status: 429, headers: buildRateLimitHeaders(rateLimit) } + ); + } + } }); export const config = { diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts new file mode 100644 index 0000000..5f1e018 --- /dev/null +++ b/tests/rate-limit.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + consumeRateLimit, + getClientIpAddress, + getRateLimitNumber, + resetRateLimitStore, +} from '../lib/rate-limit.ts'; + +test('consumeRateLimit blocks once limit is exceeded and resets after window', () => { + resetRateLimitStore(); + + const first = consumeRateLimit({ + key: 'twilio:test:127.0.0.1', + limit: 2, + windowMs: 10_000, + nowMs: 1_000, + }); + const second = consumeRateLimit({ + key: 'twilio:test:127.0.0.1', + limit: 2, + windowMs: 10_000, + nowMs: 1_100, + }); + const blocked = consumeRateLimit({ + key: 'twilio:test:127.0.0.1', + limit: 2, + windowMs: 10_000, + nowMs: 1_200, + }); + const afterWindow = consumeRateLimit({ + key: 'twilio:test:127.0.0.1', + limit: 2, + windowMs: 10_000, + nowMs: 11_500, + }); + + assert.equal(first.allowed, true); + assert.equal(second.allowed, true); + assert.equal(blocked.allowed, false); + assert.equal(blocked.retryAfterSeconds > 0, true); + assert.equal(afterWindow.allowed, true); +}); + +test('getClientIpAddress prefers x-forwarded-for first value', () => { + const request = new Request('https://example.com', { + headers: { + 'x-forwarded-for': '203.0.113.1, 198.51.100.2', + 'x-real-ip': '198.51.100.20', + }, + }); + + assert.equal(getClientIpAddress(request), '203.0.113.1'); +}); + +test('getRateLimitNumber uses fallback for invalid env input', () => { + const env = { + RATE_LIMIT_TWILIO_AUTH_MAX: 'bad', + }; + + assert.equal( + getRateLimitNumber('RATE_LIMIT_TWILIO_AUTH_MAX', 240, { env }), + 240 + ); +}); From b878c090f29fe2903e63929b1b5c342ecb75e8aa Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:04:59 -0500 Subject: [PATCH 2/2] docs: record G5 commit sha in gap changelog --- docs/PRODUCTION_READINESS_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 3b56757..b2b63af 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -296,4 +296,4 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_ENV.md` - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - - `PENDING` + - `80a23c3`