Skip to content

(SP: 3)[Security] Harden rate-limit inputs, normalize webhook subjects, and isolate test env#157

Merged
ViktorSvertoka merged 3 commits into
developfrom
lso/feat/shop
Jan 18, 2026
Merged

(SP: 3)[Security] Harden rate-limit inputs, normalize webhook subjects, and isolate test env#157
ViktorSvertoka merged 3 commits into
developfrom
lso/feat/shop

Conversation

@liudmylasovetovs
Copy link
Copy Markdown
Collaborator

@liudmylasovetovs liudmylasovetovs commented Jan 18, 2026

Description

This PR hardens rate limiting and webhook abuse controls by (1) making env-driven limits resilient to invalid/empty values, (2) enforcing a trusted-boundary model for client IP extraction to prevent header spoofing, and (3) normalizing rate-limit subjects (including IPv6) to keep keys analytics/grep-friendly. It also improves test stability by isolating env mutations and deduplicating test helpers.


Related Issue

Issue: #<issue_number>


Changes

  • Checkout RL env hardening: Parse CHECKOUT_RATE_LIMIT_MAX and CHECKOUT_RATE_LIMIT_WINDOW_SECONDS as positive integers with safe fallbacks before calling enforceRateLimit (prevents NaN/0 from disabling or corrupting RL).
  • Stripe webhook RL env hardening + policy clarity: Replace Number(process.env...) with a strict positive-int resolver; support STRIPE_WEBHOOK_MISSING_SIG_RL_* + generic STRIPE_WEBHOOK_RL_*, with legacy STRIPE_WEBHOOK_INVALID_SIG_RL_* preserved as fallback for backward compatibility.
  • Trusted-boundary IP model + IPv6-safe subjects: getClientIpFromHeaders always prefers cf-connecting-ip, and reads x-real-ip / x-forwarded-for only when TRUST_FORWARDED_HEADERS=true; IPv6 subjects are canonicalized (no :) via normalization/hashing. Added .env.example guidance for TRUST_FORWARDED_HEADERS.
  • Test isolation: Prevent env leakage in admin-csrf-contract.test.ts by saving/restoring process.env.CSRF_SECRET in a try/finally.
  • Test helper unification: Extract deriveTestIpFromIdemKey into a shared helper and replace local copies with imports (prevents drift across test files).

Database Changes (if applicable)

  • Schema migration required
  • Seed data updated
  • Breaking changes to existing queries
  • Transaction-safe migration
  • Migration tested locally on Neon

How Has This Been Tested?

  • Tested locally
  • Verified in development environment
  • Checked responsive layout (if UI-related)
  • Tested accessibility (keyboard / screen reader)

Commands (PowerShell):

  • npx vitest run .\lib\tests\rate-limit-subject.test.ts
  • npx vitest run .\lib\tests\admin-csrf-contract.test.ts
  • npx vitest run .\lib\tests\checkout-no-payments.test.ts

Screenshots (if applicable)

N/A (no UI changes)


Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors
  • Code follows project conventions
  • Scope is limited to this feature/fix
  • No unrelated refactors included
  • English used in code, commits, and docs
  • New dependencies discussed with team
  • Database migration tested locally (if applicable)
  • GitHub Projects card moved to In Review

Reviewers

Summary by CodeRabbit

Release Notes

  • New Features

    • Configurable rate limits for checkout operations via environment variables
    • Configurable rate limits for Stripe webhook operations via environment variables
    • Enhanced IP detection for rate limiting when behind trusted proxies
  • Tests

    • Added comprehensive test coverage for rate limiting functionality

✏️ Tip: You can customize this high-level summary in your review settings.

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 18, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit a196ce8
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/696d3a1f8f8dc20008f3910d
😎 Deploy Preview https://deploy-preview-157--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 18, 2026

📝 Walkthrough

Walkthrough

Introduces a subject-based rate limiting system for checkout and Stripe webhook endpoints with normalized subjects, configurable per-endpoint limits, forwarded header trust controls, and dynamic per-reason Stripe webhook rate limits via environment configuration with fallback chains.

Changes

Cohort / File(s) Summary
Environment Configuration
frontend/.env.example
Added Stripe webhook rate limit env variables (STRIPE_WEBHOOK_RL_MAX, STRIPE_WEBHOOK_INVALID_SIG_RL_*, STRIPE_WEBHOOK_MISSING_SIG_RL_*) with legacy fallback support; added TRUST_FORWARDED_HEADERS flag for x-real-ip/x-forwarded-for header trust behind proxies.
Rate Limiting Core Infrastructure
frontend/lib/security/rate-limit.ts
Major refactor: added normalizeRateLimitSubject() with IPv6 hashing and input sanitization; added normalizeRateLimitKey() for legacy key migration; new getClientIpFromHeaders() with TRUST_FORWARDED_HEADERS support and CF header prioritization; new getRateLimitSubject() deriving stable subjects from request IP or UA fingerprint; refactored enforceRateLimit() to apply normalization and potential DB key migration.
Stripe Webhook Rate Limit Resolution
frontend/lib/security/stripe-webhook-rate-limit.ts
New module exporting StripeWebhookRateLimitReason type and resolveStripeWebhookRateLimit() function that resolves max/windowSeconds from environment with per-reason (missing_sig/invalid_sig) precedence, generic fallbacks, and strict integer parsing.
API Endpoint Rate Limiting Updates
frontend/app/api/shop/checkout/route.ts, frontend/app/api/shop/webhooks/stripe/route.ts
Switched from IP-based to subject-based rate limiting via getRateLimitSubject(); introduced configurable rate limit parameters from environment (CHECKOUT_RATE_LIMIT_MAX, CHECKOUT_RATE_LIMIT_WINDOW_SECONDS); Stripe handler now uses resolveStripeWebhookRateLimit() for dynamic per-reason limits.
Test Helper Consolidation
frontend/lib/tests/helpers/ip.ts, frontend/lib/tests/helpers/makeCheckoutReq.ts, frontend/lib/tests/checkout-no-payments.test.ts
Extracted deriveTestIpFromIdemKey() to new dedicated module frontend/lib/tests/helpers/ip.ts (deterministic TEST-NET-3 IPv4 generation from idemKey); updated dependent files to import from centralized location.
Rate Limiting Test Coverage
frontend/lib/tests/rate-limit-subject.test.ts, frontend/lib/tests/rate-limit-subject-normalization.test.ts, frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts
Added three new test suites: subject derivation with header trust logic, subject normalization (IPv6 hashing, sanitization), and Stripe webhook rate limit environment precedence (per-reason vs. generic vs. legacy fallbacks).
Test Fixture Improvements
frontend/lib/tests/admin-csrf-contract.test.ts
Wrapped CSRF_SECRET environment variable modifications with try/finally for proper restoration/cleanup.

Sequence Diagram

sequenceDiagram
    participant Request
    participant getRateLimitSubject
    participant normalizeRateLimitSubject
    participant enforceRateLimit
    participant normalizeRateLimitKey
    participant Database
    
    Request->>getRateLimitSubject: extract subject from IP or UA fingerprint
    getRateLimitSubject->>normalizeRateLimitSubject: derive stable subject (hash IPv6, sanitize input)
    normalizeRateLimitSubject-->>getRateLimitSubject: normalized subject
    getRateLimitSubject-->>Request: rate limit subject ready
    
    Request->>enforceRateLimit: check/increment counter with subject as key
    enforceRateLimit->>normalizeRateLimitKey: normalize legacy key format
    normalizeRateLimitKey-->>enforceRateLimit: { legacyKey, normalizedKey }
    
    alt Key normalization occurred
        enforceRateLimit->>Database: migrate legacy key to normalized key (async)
        Database-->>enforceRateLimit: key migration complete
    end
    
    enforceRateLimit-->>Request: rate limit decision (allow/deny)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • AM1007
  • ViktorSvertoka

🐰 Subject-based shields now stand so tall,
Hashing IPv6, trusting headers with care,
Per-reason limits, fallbacks for all,
Rate limits normalized, fair and square!
Webhooks and checkouts, protected with flair.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and accurately summarizes the main security-focused changes: hardening rate-limit inputs, normalizing webhook subjects, and isolating test environments. It is specific, concise, and reflects the core objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@frontend/lib/security/rate-limit.ts`:
- Around line 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.
🧹 Nitpick comments (1)
frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts (1)

76-88: Consider adding edge case tests for zero and negative values.

The current test validates whitespace and non-numeric values fall back correctly. You might also want to explicitly test that "0" and "-1" are rejected (falling back to defaults), since these are valid integers but invalid for rate limiting.

Optional test case addition
  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('rejects zero and falls back to defaults', () => {
+    vi.stubEnv('STRIPE_WEBHOOK_RL_MAX', '0');
+    vi.stubEnv('STRIPE_WEBHOOK_RL_WINDOW_SECONDS', '0');
+
+    expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({
+      max: 30,
+      windowSeconds: 60,
+    });
+  });

Comment on lines +110 to +129
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;
}
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.

@ViktorSvertoka ViktorSvertoka merged commit 387cc55 into develop Jan 18, 2026
9 checks passed
@ViktorSvertoka ViktorSvertoka deleted the lso/feat/shop branch January 18, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants