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
15 changes: 15 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 18 additions & 6 deletions frontend/app/api/shop/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 12 additions & 13 deletions frontend/app/api/shop/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
144 changes: 134 additions & 10 deletions frontend/lib/security/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Comment on lines +110 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate the file and understand the repository structure
find . -name "rate-limit.ts" -o -name ".env.example" -o -name ".env*" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 132


🏁 Script executed:

# Get an overview of the repo structure
git ls-files | head -30

Repository: DevLoversTeam/devlovers.net

Length of output: 903


🏁 Script executed:

# Look at the full rate-limit.ts file to understand the context
wc -l frontend/lib/security/rate-limit.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 110


🏁 Script executed:

# Read the full rate-limit.ts file to understand context and usage
cat -n frontend/lib/security/rate-limit.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 10136


🏁 Script executed:

# Check .env.example to see what trust flags are documented
cat -n frontend/.env.example

Repository: DevLoversTeam/devlovers.net

Length of output: 2977


🏁 Script executed:

# Search for usages of TRUST_FORWARDED_HEADERS in the codebase
rg "TRUST_FORWARDED_HEADERS" -A 3 -B 3

Repository: DevLoversTeam/devlovers.net

Length of output: 5727


🏁 Script executed:

# Search for any deployment or infrastructure documentation
find . -type f \( -name "*.md" -o -name "*.txt" \) -exec grep -l "Cloudflare\|cloudflare\|cf-connecting-ip" {} \; 2>/dev/null

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Look at the test file to see expectations around cf-connecting-ip
cat -n frontend/lib/tests/rate-limit-subject.test.ts | head -80

Repository: DevLoversTeam/devlovers.net

Length of output: 3003


🏁 Script executed:

# Search for any additional documentation about Cloudflare assumptions
find . -type f \( -name "*.md" -o -name "DEPLOYMENT*" -o -name "INFRASTRUCTURE*" \) -exec grep -l "Cloudflare\|cloudflare\|deployment\|infrastructure" {} \; 2>/dev/null | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 91


🏁 Script executed:

# Check SECURITY.md or README for any deployment assumptions
cat frontend/README.md 2>/dev/null || echo "frontend/README.md not found"

Repository: DevLoversTeam/devlovers.net

Length of output: 1522


🏁 Script executed:

# Let's check if there are any comments or documentation in the codebase about why cf-connecting-ip is trusted unconditionally
rg "cf-connecting-ip\|cf_connecting_ip\|Cloudflare.*canonical\|canonical.*Cloudflare" -B 5 -A 5

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check if there are any other rate limiting files or usage patterns
fd "rate" --type f | grep -E "\.(ts|js)$"

Repository: DevLoversTeam/devlovers.net

Length of output: 428


🏁 Script executed:

# Look at how getClientIp is used in the actual application
rg "getClientIp\|getRateLimitSubject" --type ts -B 2 -A 2 | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Try a broader search for how rate-limit functions are used
rg "getClientIp|getRateLimitSubject" -t ts | head -50

Repository: DevLoversTeam/devlovers.net

Length of output: 2156


🏁 Script executed:

# Check stripe-webhook-rate-limit.ts to see if it has similar patterns
cat -n frontend/lib/security/stripe-webhook-rate-limit.ts | head -50

Repository: DevLoversTeam/devlovers.net

Length of output: 1883


🏁 Script executed:

# Search for where getClientIpFromHeaders is imported/used
rg "import.*getClientIp|from.*rate-limit" -t ts

Repository: DevLoversTeam/devlovers.net

Length of output: 595


🏁 Script executed:

# Let's check the checkout and stripe webhook routes to see how rate limiting is used in practice
cat -n frontend/app/api/shop/checkout/route.ts | grep -A 10 -B 10 "getRateLimitSubject\|rate.*limit" | head -40

Repository: DevLoversTeam/devlovers.net

Length of output: 1531


🏁 Script executed:

# Check if there's any configuration or deployment info about Cloudflare integration
find . -name "*.config.*" -o -name "*.env*" -o -name "vercel.json" -o -name "next.config.*" | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 289


🏁 Script executed:

# Let's verify the exact behavior: does cf-connecting-ip bypass trust checks?
cat -n frontend/lib/security/rate-limit.ts | sed -n '110,133p'

Repository: DevLoversTeam/devlovers.net

Length of output: 1027


Gate cf-connecting-ip behind an explicit trust boundary.

If any deployment isn't guaranteed to sit behind Cloudflare (or doesn't sanitize this header at the edge), clients can spoof it and evade rate limiting. The current code unconditionally accepts this header for rate-limit subjects, while x-real-ip and x-forwarded-for are properly guarded by TRUST_FORWARDED_HEADERS. Add an explicit TRUST_CF_CONNECTING_IP flag (defaulting to false for safety) and document it in .env.example.

🔒 Suggested guard for trusted CF header
-  // Always allow Cloudflare canonical header (highest priority).
-  const cf = (headers.get('cf-connecting-ip') ?? '').trim();
-  if (cf && isIP(cf)) return cf;
-
   const trustForwarded = envBool('TRUST_FORWARDED_HEADERS', false);
+  const trustCf = envBool('TRUST_CF_CONNECTING_IP', false);
+  
+  // Allow Cloudflare canonical header (highest priority) when trusted.
+  if (trustCf) {
+    const cf = (headers.get('cf-connecting-ip') ?? '').trim();
+    if (cf && isIP(cf)) return cf;
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
export function getClientIpFromHeaders(headers: Headers): string | null {
const trustForwarded = envBool('TRUST_FORWARDED_HEADERS', false);
const trustCf = envBool('TRUST_CF_CONNECTING_IP', false);
// Allow Cloudflare canonical header (highest priority) when trusted.
if (trustCf) {
const cf = (headers.get('cf-connecting-ip') ?? '').trim();
if (cf && isIP(cf)) return cf;
}
// 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) {
for (const part of xff.split(',')) {
const candidate = part.trim();
if (candidate && isIP(candidate)) return candidate;
}
🤖 Prompt for AI Agents
In `@frontend/lib/security/rate-limit.ts` around lines 110 - 129, The function
getClientIpFromHeaders currently accepts the cf-connecting-ip header
unconditionally; add a new env flag TRUST_CF_CONNECTING_IP (default false) and
gate the cf header check behind it similar to how TRUST_FORWARDED_HEADERS is
used so that cf-connecting-ip is only trusted when TRUST_CF_CONNECTING_IP is
true; update any envBool usage to read TRUST_CF_CONNECTING_IP, adjust the
early-return logic accordingly, and add documentation/example entry in
.env.example describing the flag and its default.

}

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;
Expand All @@ -56,7 +164,8 @@ export async function enforceRateLimit(params: {
}): Promise<RateLimitDecision> {
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) {
Expand All @@ -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<GateRow>(sql`
INSERT INTO api_rate_limits (key, window_started_at, count, updated_at)
VALUES (${key}, now(), 1, now())
Expand Down
80 changes: 80 additions & 0 deletions frontend/lib/security/stripe-webhook-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>,
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
),
};
}
Loading