diff --git a/frontend/.env.example b/frontend/.env.example index 6cabe34f..0eb502ab 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -70,8 +70,23 @@ CSRF_SECRET= CHECKOUT_RATE_LIMIT_MAX=10 CHECKOUT_RATE_LIMIT_WINDOW_SECONDS=300 +# Stripe webhook rate limit envs (applied per reason; reason-specific overrides generic). +# Missing signature has its own envs with fallback to generic, then legacy invalid_sig. +STRIPE_WEBHOOK_MISSING_SIG_RL_MAX=30 +STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS=60 + +# Generic Stripe webhook rate limit fallback (applies to missing_sig and invalid_sig). +STRIPE_WEBHOOK_RL_MAX=30 +STRIPE_WEBHOOK_RL_WINDOW_SECONDS=60 + +# Invalid signature envs (canonical for invalid_sig, legacy fallback for missing_sig). STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30 STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60 +# SECURITY: If true, trust x-real-ip / x-forwarded-for headers for rate limiting. +# Enable ONLY behind Cloudflare or a trusted reverse proxy that overwrites these headers. +# Default: false (empty/0/false). +TRUST_FORWARDED_HEADERS=0 + # emergency switch RATE_LIMIT_DISABLED=0 diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index b72ba0a1..c7dffe4e 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { enforceRateLimit, - getClientIp, + getRateLimitSubject, rateLimitResponse, } from '@/lib/security/rate-limit'; import { getCurrentUser } from '@/lib/auth'; @@ -233,14 +233,26 @@ 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 checkoutSubject = sessionUserId ?? getRateLimitSubject(request); + + const limitParsed = Number.parseInt( + process.env.CHECKOUT_RATE_LIMIT_MAX ?? '', + 10 + ); + const windowParsed = Number.parseInt( + process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? '', + 10 + ); + + const limit = + Number.isFinite(limitParsed) && limitParsed > 0 ? limitParsed : 10; + const windowSeconds = + Number.isFinite(windowParsed) && windowParsed > 0 ? windowParsed : 300; 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 - ), + limit, + windowSeconds, }); if (!decision.ok) { diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index ef6a0d93..9d17db77 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -15,9 +15,10 @@ import { import { markStripeAttemptFinal } from '@/lib/services/orders/payment-attempts'; import { enforceRateLimit, - getClientIp, + getRateLimitSubject, rateLimitResponse, } from '@/lib/security/rate-limit'; +import { resolveStripeWebhookRateLimit } from '@/lib/security/stripe-webhook-rate-limit'; const REFUND_FULLNESS_UNDETERMINED = 'REFUND_FULLNESS_UNDETERMINED' as const; @@ -317,13 +318,12 @@ export async function POST(request: NextRequest) { const signature = request.headers.get('stripe-signature'); if (!signature) { - const ip = getClientIp(request) ?? 'anon'; + const subject = getRateLimitSubject(request); + const rateLimit = resolveStripeWebhookRateLimit('missing_sig'); 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 - ), + key: `stripe_webhook:missing_sig:${subject}`, + limit: rateLimit.max, + windowSeconds: rateLimit.windowSeconds, }); if (!decision.ok) { @@ -353,13 +353,12 @@ export async function POST(request: NextRequest) { error instanceof Error && error.message === 'STRIPE_INVALID_SIGNATURE' ) { - const ip = getClientIp(request) ?? 'anon'; + const subject = getRateLimitSubject(request); + const rateLimit = resolveStripeWebhookRateLimit('invalid_sig'); 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 - ), + key: `stripe_webhook:invalid_sig:${subject}`, + limit: rateLimit.max, + windowSeconds: rateLimit.windowSeconds, }); if (!decision.ok) { diff --git a/frontend/lib/security/rate-limit.ts b/frontend/lib/security/rate-limit.ts index f502a787..2ba6f655 100644 --- a/frontend/lib/security/rate-limit.ts +++ b/frontend/lib/security/rate-limit.ts @@ -2,12 +2,85 @@ import 'server-only'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { isIP } from 'node:net'; +import { createHash } from 'node:crypto'; import { sql } from 'drizzle-orm'; import { db } from '@/db'; type GateRow = { window_started_at: unknown; count: unknown }; +const SUBJECT_PREFIXES = [ + 'checkout:', + 'stripe_webhook:missing_sig:', + 'stripe_webhook:invalid_sig:', +]; + +const SAFE_SUBJECT_RE = /^[a-zA-Z0-9._-]$/; + +function hashSubject(subject: string, prefix: string): string { + const digest = createHash('sha256') + .update(subject, 'utf8') + .digest('base64url'); + return `${prefix}${digest.slice(0, 16)}`; +} + +function sanitizeSubject(subject: string): string { + if (!subject) return 'anon'; + let sanitized = ''; + let lastUnderscore = false; + for (const char of subject) { + if (SAFE_SUBJECT_RE.test(char)) { + sanitized += char; + lastUnderscore = false; + continue; + } + if (!lastUnderscore) { + sanitized += '_'; + lastUnderscore = true; + } + } + sanitized = sanitized.replace(/^_+|_+$/g, ''); + if (!sanitized) return 'anon'; + if (sanitized.length > 64) return hashSubject(subject, 'h_'); + return sanitized; +} + +export function normalizeRateLimitSubject(subject: string): string { + const trimmed = subject.trim(); + if (!trimmed) return 'anon'; + + const ipKind = isIP(trimmed); // 0 | 4 | 6 + if (ipKind === 6) return hashSubject(trimmed, 'ip6_'); + if (ipKind === 4) return trimmed; + + return sanitizeSubject(trimmed); +} + +function normalizeRateLimitKey(key: string): { + legacyKey: string; + normalizedKey: string; +} { + const legacyKey = key; + if (!key) return { legacyKey, normalizedKey: key }; + const prefix = SUBJECT_PREFIXES.find(candidate => key.startsWith(candidate)); + if (prefix) { + const subject = key.slice(prefix.length); + const normalizedSubject = normalizeRateLimitSubject(subject); + if (normalizedSubject === subject) return { legacyKey, normalizedKey: key }; + return { legacyKey, normalizedKey: `${prefix}${normalizedSubject}` }; + } + const lastColon = key.lastIndexOf(':'); + if (lastColon === -1) return { legacyKey, normalizedKey: key }; + const subject = key.slice(lastColon + 1); + const normalizedSubject = normalizeRateLimitSubject(subject); + if (normalizedSubject === subject) return { legacyKey, normalizedKey: key }; + return { + legacyKey, + normalizedKey: `${key.slice(0, lastColon + 1)}${normalizedSubject}`, + }; +} + function normalizeDate(x: unknown): Date | null { if (!x) return null; if (x instanceof Date) return isNaN(x.getTime()) ? null : x; @@ -22,24 +95,59 @@ function envInt(name: string, fallback: number): number { return Number.isFinite(n) ? Math.floor(n) : fallback; } -export function getClientIp(request: NextRequest): string | null { - const h = request.headers; +function envBool(name: string, fallback: boolean): boolean { + const raw = (process.env[name] ?? '').trim().toLowerCase(); + if (!raw) return fallback; + + if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on') + return true; + if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'off') + return false; + + return fallback; +} - const cf = (h.get('cf-connecting-ip') ?? '').trim(); - if (cf) return cf; +export function getClientIpFromHeaders(headers: Headers): string | null { + // Always allow Cloudflare canonical header (highest priority). + const cf = (headers.get('cf-connecting-ip') ?? '').trim(); + if (cf && isIP(cf)) return cf; - const xr = (h.get('x-real-ip') ?? '').trim(); - if (xr) return xr; + const trustForwarded = envBool('TRUST_FORWARDED_HEADERS', false); - const xff = (h.get('x-forwarded-for') ?? '').trim(); + // Trusted boundary: if we don't trust forwarded headers and CF is missing, + // do NOT fall back to spoofable headers. + if (!trustForwarded) return null; + + const xr = (headers.get('x-real-ip') ?? '').trim(); + if (xr && isIP(xr)) return xr; + + const xff = (headers.get('x-forwarded-for') ?? '').trim(); if (xff) { - const first = xff.split(',')[0]?.trim(); - return first?.length ? first : null; + for (const part of xff.split(',')) { + const candidate = part.trim(); + if (candidate && isIP(candidate)) return candidate; + } } return null; } +export function getClientIp(request: NextRequest): string | null { + return getClientIpFromHeaders(request.headers); +} + +export function getRateLimitSubject(request: NextRequest): string { + const ip = getClientIp(request); + // Keep subject clean/stable for IPv6 (no ":"), consistent with key normalization. + if (ip) return normalizeRateLimitSubject(ip); + + const ua = (request.headers.get('user-agent') ?? '').trim(); + const al = (request.headers.get('accept-language') ?? '').trim(); + const baseString = `${ua}|${al}`; + const hash = createHash('sha256').update(baseString).digest('base64url'); + return `ua_${hash.slice(0, 16)}`; +} + export type RateLimitOk = { ok: true; remaining: number }; export type RateLimitNo = { ok: false; retryAfterSeconds: number }; export type RateLimitDecision = RateLimitOk | RateLimitNo; @@ -56,7 +164,8 @@ export async function enforceRateLimit(params: { }): Promise { const limit = Math.max(0, Math.floor(params.limit)); const windowSeconds = Math.max(1, Math.floor(params.windowSeconds)); - const key = params.key; + const { legacyKey, normalizedKey } = normalizeRateLimitKey(params.key); + const key = normalizedKey; // Allow disabling via env (for emergency): RATE_LIMIT_DISABLED=1 if (envInt('RATE_LIMIT_DISABLED', 0) === 1) { @@ -67,6 +176,21 @@ export async function enforceRateLimit(params: { return { ok: true, remaining: Number.MAX_SAFE_INTEGER }; } + if (legacyKey !== normalizedKey) { + try { + await db.execute(sql` + UPDATE api_rate_limits + SET key = ${normalizedKey} + WHERE key = ${legacyKey} + AND NOT EXISTS ( + SELECT 1 FROM api_rate_limits WHERE key = ${normalizedKey} + ) + `); + } catch { + // Ignore conflicts; fall through to use normalizedKey for enforcement. + } + } + const res = await db.execute(sql` INSERT INTO api_rate_limits (key, window_started_at, count, updated_at) VALUES (${key}, now(), 1, now()) diff --git a/frontend/lib/security/stripe-webhook-rate-limit.ts b/frontend/lib/security/stripe-webhook-rate-limit.ts new file mode 100644 index 00000000..38dbbac2 --- /dev/null +++ b/frontend/lib/security/stripe-webhook-rate-limit.ts @@ -0,0 +1,80 @@ +export type StripeWebhookRateLimitReason = 'missing_sig' | 'invalid_sig'; + +type StripeWebhookRateLimitConfig = { + max: number; + windowSeconds: number; +}; + +// Must match previous inline defaults in the webhook route. +const DEFAULT_STRIPE_WEBHOOK_RL_MAX = 30; +const DEFAULT_STRIPE_WEBHOOK_RL_WINDOW_SECONDS = 60; + +function parsePositiveIntStrict(raw: string | undefined): number | null { + if (raw === undefined) return null; + + const trimmed = raw.trim(); + if (!trimmed) return null; + + // Strict: digits only (no "+", "-", decimals, exponent, or "10abc"). + if (!/^\d+$/.test(trimmed)) return null; + + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isSafeInteger(parsed)) return null; + + return parsed > 0 ? parsed : null; +} + +function resolveEnvPositiveInt( + values: Array, + fallback: number +): number { + for (const raw of values) { + const parsed = parsePositiveIntStrict(raw); + if (parsed !== null) return parsed; + } + return fallback; +} + +export function resolveStripeWebhookRateLimit( + reason: StripeWebhookRateLimitReason +): StripeWebhookRateLimitConfig { + if (reason === 'missing_sig') { + return { + max: resolveEnvPositiveInt( + [ + process.env.STRIPE_WEBHOOK_MISSING_SIG_RL_MAX, + process.env.STRIPE_WEBHOOK_RL_MAX, + // legacy fallback to preserve current behavior: + process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX, + ], + DEFAULT_STRIPE_WEBHOOK_RL_MAX + ), + windowSeconds: resolveEnvPositiveInt( + [ + process.env.STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS, + process.env.STRIPE_WEBHOOK_RL_WINDOW_SECONDS, + // legacy fallback to preserve current behavior: + process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS, + ], + DEFAULT_STRIPE_WEBHOOK_RL_WINDOW_SECONDS + ), + }; + } + + return { + max: resolveEnvPositiveInt( + [ + process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX, + process.env.STRIPE_WEBHOOK_RL_MAX, + ], + DEFAULT_STRIPE_WEBHOOK_RL_MAX + ), + windowSeconds: resolveEnvPositiveInt( + [ + process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS, + process.env.STRIPE_WEBHOOK_RL_WINDOW_SECONDS, + ], + DEFAULT_STRIPE_WEBHOOK_RL_WINDOW_SECONDS + ), + }; +} diff --git a/frontend/lib/tests/admin-csrf-contract.test.ts b/frontend/lib/tests/admin-csrf-contract.test.ts index c2b334d0..543fdbe3 100644 --- a/frontend/lib/tests/admin-csrf-contract.test.ts +++ b/frontend/lib/tests/admin-csrf-contract.test.ts @@ -25,20 +25,29 @@ import { PATCH as patchStatus } from '@/app/api/shop/admin/products/[id]/status/ describe('P0-SEC: admin CSRF required for mutating endpoints', () => { it('admin status toggle: missing CSRF => 403 CSRF_MISSING', async () => { - process.env.CSRF_SECRET = 'test_csrf_secret'; + const prev = process.env.CSRF_SECRET; + try { + process.env.CSRF_SECRET = 'test_csrf_secret'; - const req = new NextRequest( - new Request('http://localhost/api/shop/admin/products/x/status', { - method: 'PATCH', - }) - ); + const req = new NextRequest( + new Request('http://localhost/api/shop/admin/products/x/status', { + method: 'PATCH', + }) + ); - const res = await patchStatus(req, { - params: Promise.resolve({ id: '11111111-1111-1111-1111-111111111111' }), - }); + const res = await patchStatus(req, { + params: Promise.resolve({ id: '11111111-1111-1111-1111-111111111111' }), + }); - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.code).toBe('CSRF_MISSING'); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.code).toBe('CSRF_MISSING'); + } finally { + if (prev === undefined) { + delete process.env.CSRF_SECRET; + } else { + process.env.CSRF_SECRET = prev; + } + } }); }); diff --git a/frontend/lib/tests/checkout-no-payments.test.ts b/frontend/lib/tests/checkout-no-payments.test.ts index 77458776..fb2b1905 100644 --- a/frontend/lib/tests/checkout-no-payments.test.ts +++ b/frontend/lib/tests/checkout-no-payments.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest'; import crypto from 'crypto'; import { eq, sql } from 'drizzle-orm'; import { NextRequest } from 'next/server'; - +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; import { db } from '@/db'; import { orders, products, productPrices } from '@/db/schema'; import { toDbMoney } from '@/lib/shop/money'; @@ -156,11 +156,6 @@ async function cleanupIsolatedProduct(productId: string) { ); } } -function deriveTestIpFromIdemKey(idemKey: string): string { - const hex = idemKey.replace(/[^0-9a-f]/gi, '').slice(0, 2); - const n = hex ? (parseInt(hex, 16) % 250) + 1 : 1; - return `203.0.113.${n}`; -} async function postCheckout(params: { idemKey: string; diff --git a/frontend/lib/tests/helpers/ip.ts b/frontend/lib/tests/helpers/ip.ts new file mode 100644 index 00000000..e1781a57 --- /dev/null +++ b/frontend/lib/tests/helpers/ip.ts @@ -0,0 +1,11 @@ +/** + * Derive a deterministic TEST IP from an idempotency key. + * Used only in tests to make rate-limit keys stable per request. + * + * Produces a TEST-NET-3 IPv4 address: 203.0.113.1..250 + */ +export function deriveTestIpFromIdemKey(idemKey: string): string { + const hex = idemKey.replace(/[^0-9a-f]/gi, '').slice(0, 2); + const n = hex ? (parseInt(hex, 16) % 250) + 1 : 1; + return `203.0.113.${n}`; +} diff --git a/frontend/lib/tests/helpers/makeCheckoutReq.ts b/frontend/lib/tests/helpers/makeCheckoutReq.ts index 02f12c9a..04209447 100644 --- a/frontend/lib/tests/helpers/makeCheckoutReq.ts +++ b/frontend/lib/tests/helpers/makeCheckoutReq.ts @@ -1,4 +1,5 @@ import { NextRequest } from 'next/server'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; export type CheckoutItemInput = { productId: string; @@ -7,13 +8,6 @@ export type CheckoutItemInput = { selectedColor?: string; }; -function deriveTestIpFromIdemKey(idemKey: string): string { - // беремо перші 2 hex-символи, робимо байт 1..250 - const hex = idemKey.replace(/[^0-9a-f]/gi, '').slice(0, 2); - const n = hex ? (parseInt(hex, 16) % 250) + 1 : 1; - return `203.0.113.${n}`; // TEST-NET-3 -} - export function makeCheckoutReq(params: { idempotencyKey: string; locale?: string; // mapped to Accept-Language diff --git a/frontend/lib/tests/rate-limit-subject-normalization.test.ts b/frontend/lib/tests/rate-limit-subject-normalization.test.ts new file mode 100644 index 00000000..ea5b0952 --- /dev/null +++ b/frontend/lib/tests/rate-limit-subject-normalization.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/db', () => ({ db: { execute: vi.fn() } })); + +const { normalizeRateLimitSubject } = await import('@/lib/security/rate-limit'); + +describe('normalizeRateLimitSubject', () => { + it('hashes IPv6 subjects without colons and is stable', () => { + const normalized = normalizeRateLimitSubject('::1'); + const normalizedAgain = normalizeRateLimitSubject('::1'); + expect(normalized).toBe(normalizedAgain); + expect(normalized.startsWith('ip6_')).toBe(true); + expect(normalized.includes(':')).toBe(false); + }); + + it('leaves IPv4 subjects unchanged', () => { + expect(normalizeRateLimitSubject('203.0.113.10')).toBe('203.0.113.10'); + }); + + it('sanitizes non-IP subjects', () => { + expect(normalizeRateLimitSubject('user:123')).toBe('user_123'); + }); + + it('hashes long subjects', () => { + const subject = 'user-' + 'x'.repeat(80); + const normalized = normalizeRateLimitSubject(subject); + expect(normalized).toMatch(/^h_/); + expect(normalized).toBe(normalizeRateLimitSubject(subject)); + }); +}); diff --git a/frontend/lib/tests/rate-limit-subject.test.ts b/frontend/lib/tests/rate-limit-subject.test.ts new file mode 100644 index 00000000..87bd4458 --- /dev/null +++ b/frontend/lib/tests/rate-limit-subject.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +vi.mock('@/db', () => ({ db: { execute: vi.fn() } })); + +import { NextRequest } from 'next/server'; +import { + getClientIpFromHeaders, + getRateLimitSubject, +} from '@/lib/security/rate-limit'; + +const prevTrust = process.env.TRUST_FORWARDED_HEADERS; + +afterEach(() => { + process.env.TRUST_FORWARDED_HEADERS = prevTrust; +}); + +describe('rate limit subject', () => { + it('returns null for x-forwarded-for when TRUST_FORWARDED_HEADERS is false', () => { + process.env.TRUST_FORWARDED_HEADERS = '0'; + + const headers = new Headers({ + 'x-forwarded-for': '203.0.113.10', + }); + + expect(getClientIpFromHeaders(headers)).toBeNull(); + }); + + it('returns null for x-real-ip when TRUST_FORWARDED_HEADERS is false', () => { + process.env.TRUST_FORWARDED_HEADERS = 'false'; + + const headers = new Headers({ + 'x-real-ip': '198.51.100.4', + }); + + expect(getClientIpFromHeaders(headers)).toBeNull(); + }); + + it('uses first valid x-forwarded-for IP when TRUST_FORWARDED_HEADERS is true', () => { + process.env.TRUST_FORWARDED_HEADERS = '1'; + + const headers = new Headers({ + 'x-forwarded-for': 'unknown, 198.51.100.7, 203.0.113.9', + }); + + expect(getClientIpFromHeaders(headers)).toBe('198.51.100.7'); + }); + + it('prefers cf-connecting-ip over other headers (even when trust is true)', () => { + process.env.TRUST_FORWARDED_HEADERS = '1'; + + const headers = new Headers({ + 'cf-connecting-ip': '203.0.113.1', + 'x-real-ip': '198.51.100.2', + 'x-forwarded-for': '198.51.100.3', + }); + + expect(getClientIpFromHeaders(headers)).toBe('203.0.113.1'); + }); + + it('ignores invalid cf-connecting-ip and falls back to null when trust is false', () => { + process.env.TRUST_FORWARDED_HEADERS = '0'; + + const headers = new Headers({ + 'cf-connecting-ip': 'not-an-ip', + 'x-real-ip': '198.51.100.4', + }); + + // cf invalid; trust disabled => must NOT accept x-real-ip + expect(getClientIpFromHeaders(headers)).toBeNull(); + }); + + it('ignores invalid cf-connecting-ip and uses x-real-ip when trust is true', () => { + process.env.TRUST_FORWARDED_HEADERS = 'true'; + + const headers = new Headers({ + 'cf-connecting-ip': 'not-an-ip', + 'x-real-ip': '198.51.100.4', + }); + + expect(getClientIpFromHeaders(headers)).toBe('198.51.100.4'); + }); + + it('returns clean ip6_ subject for IPv6 client ip (no ":")', () => { + process.env.TRUST_FORWARDED_HEADERS = '0'; + + const headers = new Headers({ + 'cf-connecting-ip': '2001:db8::1', + }); + const request = new NextRequest( + new Request('http://localhost/test', { headers }) + ); + + const subjectA = getRateLimitSubject(request); + const subjectB = getRateLimitSubject(request); + + expect(subjectA).toBe(subjectB); + expect(subjectA.startsWith('ip6_')).toBe(true); + expect(subjectA.includes(':')).toBe(false); + }); + + it('returns stable ua hash when no IP is available', () => { + process.env.TRUST_FORWARDED_HEADERS = '0'; + + const headers = new Headers({ + 'user-agent': 'Mozilla/5.0 (RateLimitTest)', + 'accept-language': 'en-US,en;q=0.9', + }); + const request = new NextRequest( + new Request('http://localhost/test', { headers }) + ); + + const subjectA = getRateLimitSubject(request); + const subjectB = getRateLimitSubject(request); + + expect(subjectA).toBe(subjectB); + expect(subjectA.startsWith('ua_')).toBe(true); + expect(subjectA.includes(':')).toBe(false); + }); +}); diff --git a/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts b/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts new file mode 100644 index 00000000..73fe65e5 --- /dev/null +++ b/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resolveStripeWebhookRateLimit } from '@/lib/security/stripe-webhook-rate-limit'; + +describe('stripe webhook rate limit env precedence', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('uses defaults (30/60) when no env vars are set', () => { + expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ + max: 30, + windowSeconds: 60, + }); + + expect(resolveStripeWebhookRateLimit('invalid_sig')).toEqual({ + max: 30, + windowSeconds: 60, + }); + }); + it('uses invalid_sig envs as legacy fallback for missing_sig when only invalid_sig is set', () => { + vi.stubEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', '11'); + vi.stubEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS', '111'); + + expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ + max: 11, + windowSeconds: 111, + }); + + expect(resolveStripeWebhookRateLimit('invalid_sig')).toEqual({ + max: 11, + windowSeconds: 111, + }); + }); + + it('prefers missing_sig envs when set; invalid_sig remains its own', () => { + vi.stubEnv('STRIPE_WEBHOOK_MISSING_SIG_RL_MAX', '22'); + vi.stubEnv('STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS', '222'); + vi.stubEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', '33'); + vi.stubEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS', '333'); + + expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ + max: 22, + windowSeconds: 222, + }); + + expect(resolveStripeWebhookRateLimit('invalid_sig')).toEqual({ + max: 33, + windowSeconds: 333, + }); + }); + + it('uses generic envs for both reasons when only generic is set', () => { + vi.stubEnv('STRIPE_WEBHOOK_RL_MAX', '44'); + vi.stubEnv('STRIPE_WEBHOOK_RL_WINDOW_SECONDS', '444'); + + expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ + max: 44, + windowSeconds: 444, + }); + + expect(resolveStripeWebhookRateLimit('invalid_sig')).toEqual({ + max: 44, + windowSeconds: 444, + }); + }); + + it('supports partial config (field-by-field): missing_sig MAX can override while WINDOW falls back', () => { + vi.stubEnv('STRIPE_WEBHOOK_MISSING_SIG_RL_MAX', '66'); + vi.stubEnv('STRIPE_WEBHOOK_RL_WINDOW_SECONDS', '555'); // fallback source for window + + expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ + max: 66, + windowSeconds: 555, + }); + }); + + it('ignores empty/whitespace and non-numeric env values (falls back safely)', () => { + vi.stubEnv('STRIPE_WEBHOOK_RL_MAX', ' '); + vi.stubEnv('STRIPE_WEBHOOK_RL_WINDOW_SECONDS', 'nope'); + + expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ + max: 30, + windowSeconds: 60, + }); + expect(resolveStripeWebhookRateLimit('invalid_sig')).toEqual({ + max: 30, + windowSeconds: 60, + }); + }); + + it('prefers reason-specific envs over generic when both are set', () => { + vi.stubEnv('STRIPE_WEBHOOK_RL_MAX', '55'); + vi.stubEnv('STRIPE_WEBHOOK_RL_WINDOW_SECONDS', '555'); + vi.stubEnv('STRIPE_WEBHOOK_MISSING_SIG_RL_MAX', '66'); + vi.stubEnv('STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS', '666'); + vi.stubEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', '77'); + vi.stubEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS', '777'); + + expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ + max: 66, + windowSeconds: 666, + }); + + expect(resolveStripeWebhookRateLimit('invalid_sig')).toEqual({ + max: 77, + windowSeconds: 777, + }); + }); +});