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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
33 changes: 33 additions & 0 deletions app/api/stripe/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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':
Expand Down
53 changes: 52 additions & 1 deletion app/api/twilio/sms/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string>;
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');
Expand Down
53 changes: 52 additions & 1 deletion app/api/twilio/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string>;
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;
Expand Down
59 changes: 58 additions & 1 deletion app/api/twilio/voice/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,20 +27,75 @@ 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<string, string>;
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 });
}

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,
Expand Down
7 changes: 7 additions & 0 deletions docs/PRODUCTION_ENV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions docs/PRODUCTION_READINESS_GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
- `80a23c3`
Loading