Skip to content

(SP: 3) [Security] Harden admin CSRF gate + add DB-backed rate limiting for checkout and Stripe webhooks#154

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

(SP: 3) [Security] Harden admin CSRF gate + add DB-backed rate limiting for checkout and Stripe webhooks#154
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 critical security gates in the shop module by tightening admin CSRF verification semantics and adding a durable, DB-backed rate limit layer for high-risk public endpoints (checkout and Stripe webhooks). The goal is to ensure fail-closed behavior and predictable abuse resistance without relying on in-memory limits.


Related Issue

Issue: #<issue_number>


Changes

  • Fixed admin CSRF purpose scoping to avoid token reuse across unrelated actions and ensure fail-closed behavior for cookie-based admin mutations.
  • Implemented DB-backed API rate limiting via api_rate_limits table (durable counters with window tracking) for:
    • /api/shop/checkout (abuse protection)
    • /api/shop/webhooks/stripe invalid/missing signature paths (brute-force noise control)
  • Added/updated contracts and test harness adjustments to keep rate limiting enabled without introducing flaky tests.

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 / checks

  • npx drizzle-kit migrate
  • npx vitest run .\lib\tests\admin-product-sale-contract.test.ts
  • npx vitest run .\lib\tests\admin-product-patch-price-config-error-contract.test.ts
  • Manual verification:
    • Checkout rate limit returns 429 with Retry-After
    • Stripe webhook invalid/missing signature rate limit returns 429 after threshold and writes api_rate_limits rows

Screenshots (if applicable)

N/A (backend/security 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

    • Added rate limiting to checkout and webhook processing to prevent abuse.
    • Implemented security token protection for admin product operations (create, update, delete, status changes) and refund processing.
  • Bug Fixes

    • Enhanced error messaging for expired or invalid security tokens.
  • Chores

    • Added environment variables for rate limiting configuration.

✏️ 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 d319208
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/696c60abff35f60009dfe3f4
😎 Deploy Preview https://deploy-preview-154--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

This PR implements CSRF protection for admin product/order operations and adds DB-backed rate limiting for checkout and Stripe webhook processing. It introduces rate limit configuration environment variables, a database table for tracking request counts, server-side utilities for CSRF validation and rate enforcement, and propagates CSRF tokens through admin UI components and API routes.

Changes

Cohort / File(s) Summary
Environment & Configuration
frontend/.env.example
Added rate limit environment variables: CHECKOUT_RATE_LIMIT_MAX, CHECKOUT_RATE_LIMIT_WINDOW_SECONDS, STRIPE_WEBHOOK_INVALID_SIG_RL_MAX, STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS, and RATE_LIMIT_DISABLED emergency switch.
Admin Product CSRF Protection – Page Layer
frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx, frontend/app/[locale]/shop/admin/products/new/page.tsx, frontend/app/[locale]/shop/admin/products/page.tsx
Generated CSRF tokens via issueCsrfToken() with action scopes (admin:products:update, admin:products:create) and passed tokens as csrfToken prop to ProductForm and AdminProductStatusToggle components.
Admin Product CSRF Protection – Component Layer
frontend/app/[locale]/shop/admin/products/_components/product-form.tsx, frontend/components/shop/admin/admin-product-status-toggle.tsx
Added required csrfToken prop, enforced token presence before submission, attached token to API requests via x-csrf-token header, and enhanced error handling to differentiate CSRF failures (CSRF_MISSING, CSRF_INVALID) with user-facing messages.
Admin Product CSRF Protection – API Routes
frontend/app/api/shop/admin/products/route.ts, frontend/app/api/shop/admin/products/[id]/route.ts, frontend/app/api/shop/admin/products/[id]/status/route.ts, frontend/app/api/shop/admin/orders/[id]/refund/route.ts
Integrated requireAdminCsrf() validation checks after authentication; returns 403 with CSRF error codes if token is missing or invalid; prevents business logic execution on CSRF failure.
Rate Limiting Infrastructure – Utilities
frontend/lib/security/rate-limit.ts, frontend/lib/security/admin-csrf.ts
Added enforceRateLimit() for DB-backed fixed-window rate limiting with atomic upsert logic, getClientIp() to extract client IP from request headers, rateLimitResponse() to build standardized 429 responses, and requireAdminCsrf() for server-side CSRF validation with dual token sources (header or form field).
Rate Limiting – Checkout & Webhooks
frontend/app/api/shop/checkout/route.ts, frontend/app/api/shop/webhooks/stripe/route.ts
Applied DB-backed rate limiting to checkout (per user ID or IP) and to Stripe webhook signature failures (missing or invalid signature paths), with early-exit responses and retry-after guidance.
Database Schema & Migrations
frontend/db/schema/shop.ts, frontend/drizzle/0004_add_api_rate_limits.sql, frontend/drizzle/meta/_journal.json
Created api_rate_limits table with key (PK), window_started_at, count, updated_at, and non-negative constraint; added migration and journal entry.
Database Snapshot
frontend/drizzle/meta/0004_snapshot.json
Added comprehensive schema snapshot reflecting database state including the new api_rate_limits table and related table definitions.
Test Infrastructure Updates
frontend/lib/tests/admin-csrf-contract.test.ts, frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts, frontend/lib/tests/admin-product-sale-contract.test.ts, frontend/lib/tests/checkout-no-payments.test.ts, frontend/lib/tests/helpers/makeCheckoutReq.ts
Added CSRF mocks and admin API mocks in test setup, introduced helpers (deriveTestIpFromIdemKey(), countMovesForProduct()) to derive test IPs from idempotency keys and centralize move-count queries, updated checkout test infrastructure to inject x-forwarded-for headers and support rate-limiting test scenarios.
Project Structure
frontend/project-structure.txt
Updated to reflect new files: security utilities (lib/security/admin-csrf.ts), database migrations and snapshots, and test files.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant AdminPage as Admin Page
    participant ProductForm
    participant API as Admin API Route
    participant CSRF as CSRF Validator
    participant DB as Database

    Client->>AdminPage: View product edit form
    AdminPage->>AdminPage: Call issueCsrfToken('admin:products:update')
    AdminPage->>ProductForm: Render with csrfToken prop
    Client->>ProductForm: Submit form with changes
    ProductForm->>ProductForm: Validate csrfToken present
    ProductForm->>API: PATCH with x-csrf-token header
    API->>CSRF: requireAdminCsrf(request, 'admin:products:update', formData)
    CSRF->>CSRF: Extract token from header or form field
    alt Token Missing
        CSRF->>API: Return 403 {code: CSRF_MISSING}
        API->>Client: 403 CSRF_MISSING
        Client->>ProductForm: Display error: "Security token expired"
    else Token Invalid
        CSRF->>DB: Call verifyCsrfToken(token, purpose)
        DB->>CSRF: Verification failed
        CSRF->>API: Return 403 {code: CSRF_INVALID}
        API->>Client: 403 CSRF_INVALID
        Client->>ProductForm: Display error: "Security token expired"
    else Token Valid
        CSRF->>API: Return null (continue processing)
        API->>DB: Update product
        DB->>API: Success
        API->>Client: 200 {product}
        Client->>ProductForm: Display success
    end
Loading
sequenceDiagram
    participant Client
    participant CheckoutAPI as Checkout API Route
    participant RateLimit as Rate Limiter
    participant DB as Database
    participant Response

    Client->>CheckoutAPI: POST /api/shop/checkout (user_id or IP: 192.168.1.1)
    CheckoutAPI->>CheckoutAPI: Extract subject (user_id or client IP)
    CheckoutAPI->>CheckoutAPI: Compute key = "checkout:192.168.1.1"
    CheckoutAPI->>RateLimit: enforceRateLimit({key, limit: 10, windowSeconds: 300})
    RateLimit->>DB: Upsert api_rate_limits (atomic)
    alt Window Expired
        DB->>DB: Reset count to 1, window_started_at = now()
        DB->>RateLimit: {ok: true, remaining: 9}
    else Within Window and Under Limit
        DB->>DB: Increment count, count = 5
        DB->>RateLimit: {ok: true, remaining: 5}
    else Limit Exceeded
        DB->>DB: count = 11 (already exceeded)
        DB->>RateLimit: {ok: false, retryAfterSeconds: 250}
    end
    alt Rate Limit Exceeded
        RateLimit->>Response: 429 {code: 'RATE_LIMIT', retryAfterSeconds: 250}
        Response->>Client: 429 Too Many Requests
    else Rate Limit OK
        RateLimit->>CheckoutAPI: {ok: true}
        CheckoutAPI->>CheckoutAPI: Process checkout logic
        CheckoutAPI->>Response: 200 or error response
        Response->>Client: Response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • AM1007
  • ViktorSvertoka

🐰 Hopping with glee, I've brought security's key,
CSRF tokens dance, rate limits decree,
No floods of requests shall bother our shop,
Admin ops guarded—attacks stop, stop, stop! 🛡️✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.90% 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 pull request title directly and specifically describes the main security changes: hardening admin CSRF gate and adding database-backed rate limiting for checkout and Stripe webhooks, which comprehensively covers 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d31920853c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 21 to +23
await requireAdminApi(request);
const csrfRes = requireAdminCsrf(request, 'admin:orders:refund');
if (csrfRes) return csrfRes;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Provide CSRF token for refund requests

The refund endpoint now hard-requires requireAdminCsrf, but the only caller (frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx) still posts without x-csrf-token or csrfToken form field. That means every admin refund will now return 403 CSRF_MISSING even for valid admins, effectively breaking refunds in the UI. Either pass an issued token into RefundButton and set the header, or allow this specific endpoint to accept the existing request format.

Useful? React with 👍 / 👎.

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: 5

🤖 Fix all issues with AI agents
In `@frontend/app/api/shop/checkout/route.ts`:
- Around line 234-251: The env parsing for CHECKOUT_RATE_LIMIT_MAX and
CHECKOUT_RATE_LIMIT_WINDOW_SECONDS can produce NaN/0; update the code that
builds the enforceRateLimit call (the variables passed for limit and
windowSeconds) to parse and validate the env values (use parseInt/Number and
check Number.isFinite and >0), and fall back to sane defaults (e.g., 10 and 300)
when the env is missing, non-numeric, or <=0; keep the checkoutSubject logic
as-is and pass the validated numeric values into enforceRateLimit.

In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 323-326: The environment-driven numeric settings
(STRIPE_WEBHOOK_INVALID_SIG_RL_MAX and
STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS) are converted with Number(...)
which can yield NaN; update the code that builds the rate-limit config (the
object with properties limit and windowSeconds) to robustly parse those env
values using a safe integer parser and fallback to the default (e.g., use
parseInt and Number.isFinite or Number.isInteger checks, or coerce with
Math.floor and isFinite) so if the env is invalid it falls back to 30 and 60
respectively; apply the same defensive parsing to the analogous block that sets
those env-driven limits later in the file (the second occurrence around the
STRIPE_WEBHOOK_INVALID_SIG_* variables).
- Around line 320-322: The code uses getClientIp() (used when building the
rate-limit key for enforceRateLimit and the `stripe_webhook:missing_sig:${ip}`
logic) which falls back to spoofable headers; ensure the webhook only trusts
proxy-provided addresses by adding a deployment-configured trusted-proxy/headers
check (e.g., require Cloudflare/CF-Connecting-IP or a configured TRUSTED_PROXY
flag) or reject requests that only provide x-forwarded-for; update getClientIp
usage (and the related logic around enforceRateLimit and the other occurrence at
lines ~356-358) to consult that trusted-proxy config before accepting
X-Forwarded-For, and document the requirement (“must be deployed behind
Cloudflare or reverse proxy that overwrites X-Forwarded-For”) in deployment
docs.

In `@frontend/lib/security/rate-limit.ts`:
- Around line 25-40: getClientIp currently trusts x-real-ip and x-forwarded-for
which can be spoofed; modify getClientIp to only use those headers when a
configured trust mechanism is enabled: add an environment-driven flag (e.g.
TRUST_FORWARDED_HEADERS, default false) and/or an allowlist of proxy IP ranges,
then change getClientIp to return cf-connecting-ip if present, and only parse
x-real-ip/x-forwarded-for when TRUST_FORWARDED_HEADERS is true or the request
originates from a known proxy IP; update references to the getClientIp function
to rely on the new behavior.

In `@frontend/lib/tests/admin-csrf-contract.test.ts`:
- Around line 28-42: The test sets process.env.CSRF_SECRET directly which can
leak into other tests; capture the original value of process.env.CSRF_SECRET
before mutating it and restore it after the test (either in an afterEach hook or
a finally block around the test) so subsequent tests are unaffected; locate the
mutation in admin-csrf-contract.test.ts where process.env.CSRF_SECRET =
'test_csrf_secret' and ensure restoration of the original value around the call
to patchStatus and assertions.
🧹 Nitpick comments (1)
frontend/lib/tests/checkout-no-payments.test.ts (1)

159-191: Consider reusing a shared test IP helper to avoid drift.

deriveTestIpFromIdemKey now exists in multiple test helpers; exporting it from a single helper would keep rate‑limit test IP generation consistent.

Comment on lines +234 to +251
// P1: rate limit checkout (cross-instance, DB-backed)
// Policy: allow reasonable retries; block abusive burst.
const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon';

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
),
});

if (!decision.ok) {
return rateLimitResponse({
retryAfterSeconds: decision.retryAfterSeconds,
details: { scope: 'checkout' },
});
}
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

Guard rate-limit env parsing to avoid NaN/zero defaults.

If CHECKOUT_RATE_LIMIT_MAX or CHECKOUT_RATE_LIMIT_WINDOW_SECONDS is unset or non-numeric, Number(...) can yield NaN or 0, which may block all requests or disable limits unexpectedly (Line 240–243). Consider validating and falling back to sane defaults.

🔧 Suggested fix
-  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
-    ),
-  });
+  const limitRaw = Number(process.env.CHECKOUT_RATE_LIMIT_MAX);
+  const windowRaw = Number(process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS);
+  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10;
+  const windowSeconds =
+    Number.isFinite(windowRaw) && windowRaw > 0 ? windowRaw : 300;
+
+  const decision = await enforceRateLimit({
+    key: `checkout:${checkoutSubject}`,
+    limit,
+    windowSeconds,
+  });
📝 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
// P1: rate limit checkout (cross-instance, DB-backed)
// Policy: allow reasonable retries; block abusive burst.
const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon';
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
),
});
if (!decision.ok) {
return rateLimitResponse({
retryAfterSeconds: decision.retryAfterSeconds,
details: { scope: 'checkout' },
});
}
// P1: rate limit checkout (cross-instance, DB-backed)
// Policy: allow reasonable retries; block abusive burst.
const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon';
const limitRaw = Number(process.env.CHECKOUT_RATE_LIMIT_MAX);
const windowRaw = Number(process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS);
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10;
const windowSeconds =
Number.isFinite(windowRaw) && windowRaw > 0 ? windowRaw : 300;
const decision = await enforceRateLimit({
key: `checkout:${checkoutSubject}`,
limit,
windowSeconds,
});
if (!decision.ok) {
return rateLimitResponse({
retryAfterSeconds: decision.retryAfterSeconds,
details: { scope: 'checkout' },
});
}
🤖 Prompt for AI Agents
In `@frontend/app/api/shop/checkout/route.ts` around lines 234 - 251, The env
parsing for CHECKOUT_RATE_LIMIT_MAX and CHECKOUT_RATE_LIMIT_WINDOW_SECONDS can
produce NaN/0; update the code that builds the enforceRateLimit call (the
variables passed for limit and windowSeconds) to parse and validate the env
values (use parseInt/Number and check Number.isFinite and >0), and fall back to
sane defaults (e.g., 10 and 300) when the env is missing, non-numeric, or <=0;
keep the checkoutSubject logic as-is and pass the validated numeric values into
enforceRateLimit.

Comment on lines +320 to +322
const ip = getClientIp(request) ?? 'anon';
const decision = await enforceRateLimit({
key: `stripe_webhook:missing_sig:${ip}`,
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, find the implementation of getClientIp
find . -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 934


🏁 Script executed:

# Search for getClientIp implementation
rg "function getClientIp|const getClientIp|export.*getClientIp" --type ts --type tsx --type js

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Look at the Stripe webhook route file
wc -l frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 122


🏁 Script executed:

# Read the specific lines mentioned in the review
sed -n '310,330p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 746


🏁 Script executed:

# Also check lines 356-358
sed -n '350,365p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 551


🏁 Script executed:

# Search for where enforceRateLimit and getClientIp are imported from
head -50 frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1748


🏁 Script executed:

# Find the rate-limit utility file
find . -path "*/lib/security/rate-limit*" -type f

Repository: DevLoversTeam/devlovers.net

Length of output: 108


🏁 Script executed:

# Search for the rate-limit file more broadly
fd "rate-limit" --type f

Repository: DevLoversTeam/devlovers.net

Length of output: 106


🏁 Script executed:

# Search for the lib/security directory
ls -la frontend/lib/security/ 2>/dev/null || echo "Path not found, searching..."

Repository: DevLoversTeam/devlovers.net

Length of output: 370


🏁 Script executed:

# Search more broadly for getClientIp implementation
rg "getClientIp" --type ts --type js -A 5 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 3896


🏁 Script executed:

# Read the full rate-limit.ts file to see the complete getClientIp implementation
cat -n frontend/lib/security/rate-limit.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 5200


IP-spoofing risk requires deployment boundary verification.

getClientIp checks cf-connecting-ip first (good), but falls back to x-forwarded-for which clients can spoof if not behind a trusted proxy. If your deployment is behind Cloudflare or a reverse proxy that overwrites these headers, this is safe. If the webhook endpoint is exposed directly to untrusted clients relying on x-forwarded-for, rate limits can be bypassed by rotating the header.

Action: Document your deployment boundary (e.g., "deployed behind Cloudflare" or "reverse proxy at X-Real-IP-trusted boundary") or add explicit configuration to specify trusted proxy headers. Consider adding IP validation or rejecting requests from untrusted sources.

Also applies to: 356-358

🤖 Prompt for AI Agents
In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 320 - 322, The
code uses getClientIp() (used when building the rate-limit key for
enforceRateLimit and the `stripe_webhook:missing_sig:${ip}` logic) which falls
back to spoofable headers; ensure the webhook only trusts proxy-provided
addresses by adding a deployment-configured trusted-proxy/headers check (e.g.,
require Cloudflare/CF-Connecting-IP or a configured TRUSTED_PROXY flag) or
reject requests that only provide x-forwarded-for; update getClientIp usage (and
the related logic around enforceRateLimit and the other occurrence at lines
~356-358) to consult that trusted-proxy config before accepting X-Forwarded-For,
and document the requirement (“must be deployed behind Cloudflare or reverse
proxy that overwrites X-Forwarded-For”) in deployment docs.

Comment on lines +323 to +326
limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
windowSeconds: Number(
process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
),
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

Guard against NaN in env‑driven limits.

Number(process.env...) can produce NaN (e.g., misconfigured env), which then propagates into SQL and can error or effectively disable limits. Prefer a safe integer parser with a fallback.

🛠️ Proposed fix
+function readIntEnv(name: string, fallback: number) {
+  const raw = process.env[name];
+  const parsed = Number.parseInt(raw ?? '', 10);
+  return Number.isFinite(parsed) ? parsed : fallback;
+}
@@
-    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
-      ),
-    });
+    const decision = await enforceRateLimit({
+      key: `stripe_webhook:missing_sig:${ip}`,
+      limit: readIntEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', 30),
+      windowSeconds: readIntEnv(
+        'STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS',
+        60
+      ),
+    });
@@
-      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
-        ),
-      });
+      const decision = await enforceRateLimit({
+        key: `stripe_webhook:invalid_sig:${ip}`,
+        limit: readIntEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', 30),
+        windowSeconds: readIntEnv(
+          'STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS',
+          60
+        ),
+      });

Also applies to: 359-362

🤖 Prompt for AI Agents
In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 323 - 326, The
environment-driven numeric settings (STRIPE_WEBHOOK_INVALID_SIG_RL_MAX and
STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS) are converted with Number(...)
which can yield NaN; update the code that builds the rate-limit config (the
object with properties limit and windowSeconds) to robustly parse those env
values using a safe integer parser and fallback to the default (e.g., use
parseInt and Number.isFinite or Number.isInteger checks, or coerce with
Math.floor and isFinite) so if the env is invalid it falls back to 30 and 60
respectively; apply the same defensive parsing to the analogous block that sets
those env-driven limits later in the file (the second occurrence around the
STRIPE_WEBHOOK_INVALID_SIG_* variables).

Comment on lines +25 to +40
export function getClientIp(request: NextRequest): string | null {
const h = request.headers;

const cf = (h.get('cf-connecting-ip') ?? '').trim();
if (cf) return cf;

const xr = (h.get('x-real-ip') ?? '').trim();
if (xr) return xr;

const xff = (h.get('x-forwarded-for') ?? '').trim();
if (xff) {
const first = xff.split(',')[0]?.trim();
return first?.length ? first : null;
}

return null;
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, locate and read the file
find . -name "rate-limit.ts" -type f

Repository: DevLoversTeam/devlovers.net

Length of output: 108


🏁 Script executed:

# Check the file content around lines 25-40
cat -n frontend/lib/security/rate-limit.ts | head -50

Repository: DevLoversTeam/devlovers.net

Length of output: 1904


🏁 Script executed:

# Search for how NextRequest is imported and used
rg "NextRequest" frontend/lib/security/rate-limit.ts -A 3 -B 3

Repository: DevLoversTeam/devlovers.net

Length of output: 435


🏁 Script executed:

# Check for TRUST_PROXY or proxy-related configurations
rg "TRUST_PROXY|trust.?proxy|proxy" --type ts --type js frontend/ -i

Repository: DevLoversTeam/devlovers.net

Length of output: 130


🏁 Script executed:

# Look for how getClientIp is used in rate limiting
rg "getClientIp" --type ts --type js -A 5 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 3896


🏁 Script executed:

# Check the proxy.ts file to understand deployment setup
cat -n frontend/proxy.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 3475


🏁 Script executed:

# Search for Next.js version and configuration
cat -n next.config.ts next.config.js | head -50

Repository: DevLoversTeam/devlovers.net

Length of output: 164


🏁 Script executed:

# Check for deployment/middleware configuration
find . -name "middleware.ts" -o -name "vercel.json" -o -name ".env*" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 94


🏁 Script executed:

# Search for any documentation or comments about IP handling
rg "ip|IP|forwarded|proxy" frontend/lib/security/ -i -B 2 -A 2

Repository: DevLoversTeam/devlovers.net

Length of output: 1263


🏁 Script executed:

# Check if there's any request IP handling in middleware or auth
cat -n frontend/middleware.ts 2>/dev/null || echo "No middleware.ts found"

Repository: DevLoversTeam/devlovers.net

Length of output: 93


🏁 Script executed:

# Check .env.example for any proxy or Cloudflare config hints
cat -n frontend/.env.example

Repository: DevLoversTeam/devlovers.net

Length of output: 2139


🏁 Script executed:

# Check for vercel.json or any deployment config
find . -name "vercel.json" -o -name "netlify.toml" -o -name "dockerfile*" 2>/dev/null

Repository: DevLoversTeam/devlovers.net

Length of output: 85


🏁 Script executed:

# Search for any Cloudflare-specific configuration or comments
rg "cloudflare|cf-" --type ts --type js -i

Repository: DevLoversTeam/devlovers.net

Length of output: 161


🏁 Script executed:

# Check if there's any documentation about deployment setup
find . -name "README*" -o -name "DEPLOY*" -o -name "docs" -type d 2>/dev/null | head -5

Repository: DevLoversTeam/devlovers.net

Length of output: 149


🏁 Script executed:

# Check Netlify deployment config
cat -n ./netlify.toml

Repository: DevLoversTeam/devlovers.net

Length of output: 218


🏁 Script executed:

# Check README for deployment context
cat -n ./README.md | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 725


🏁 Script executed:

# Look for any configuration about trusted proxies or environment-specific settings
rg "netlify|cloudflare|TRUST|proxy" frontend/ -i -l

Repository: DevLoversTeam/devlovers.net

Length of output: 293


🏁 Script executed:

# Check Next.js version to understand IP handling capabilities
cat -n frontend/package.json | grep -A 2 -B 2 "next"

Repository: DevLoversTeam/devlovers.net

Length of output: 900


🏁 Script executed:

# Search for any documentation or comments about IP extraction in the codebase
rg "\.ip|request\.ip" frontend/ -B 3 -A 3

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Verify the actual HTTP flow: check if there's any middleware that might already extract or validate IP
rg "request.headers|NextRequest" frontend/app/api -A 5 | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 7449


🏁 Script executed:

# Search for Netlify documentation or discussions about request.ip and header forwarding
rg "netlify|x-forwarded-for|x-real-ip" frontend/ -l

Repository: DevLoversTeam/devlovers.net

Length of output: 249


🏁 Script executed:

# Check if there's any error handling or logging around IP extraction
rg "getClientIp" frontend/ -B 5 -A 10

Repository: DevLoversTeam/devlovers.net

Length of output: 7166


🏁 Script executed:

# Verify the actual checkout endpoint to understand the risk impact
cat -n frontend/app/api/shop/checkout/route.ts | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 3501


🏁 Script executed:

# Check if there are any comments or documentation about Cloudflare being mandatory
rg "cloudflare|cf-connecting" frontend/ README.md -i -B 3 -A 3

Repository: DevLoversTeam/devlovers.net

Length of output: 2334


🏁 Script executed:

# Verify the exact Netlify setup and whether direct origin access is possible
cat -n ./netlify.toml

Repository: DevLoversTeam/devlovers.net

Length of output: 218


Validate trust boundary for forwarded IP headers to prevent spoofing.

The x-real-ip and x-forwarded-for fallbacks can be spoofed by direct clients to bypass rate limits on checkout and webhook endpoints. While cf-connecting-ip (Cloudflare) is trustworthy and checked first, the subsequent fallbacks lack validation. On Netlify, these headers are not automatically stripped or sanitized.

Consider one of:

  1. Only trust cf-connecting-ip when available — restrict fallbacks to Cloudflare-originated traffic by returning null if neither Cloudflare nor authenticated context provides the IP.
  2. Validate against known proxy IPs — if Netlify or another trusted proxy is the only legitimate source, maintain a allowlist of their IP ranges and reject other x-forwarded-for claims.
  3. Add environment-based trust control — introduce a TRUST_FORWARDED_HEADERS flag (default off) to skip spoofable headers unless explicitly enabled in trusted environments.

The rate limiting impact is moderate since unauthenticated checkout is already gated by signature/auth checks downstream, but this removes a potential abuse vector.

🤖 Prompt for AI Agents
In `@frontend/lib/security/rate-limit.ts` around lines 25 - 40, getClientIp
currently trusts x-real-ip and x-forwarded-for which can be spoofed; modify
getClientIp to only use those headers when a configured trust mechanism is
enabled: add an environment-driven flag (e.g. TRUST_FORWARDED_HEADERS, default
false) and/or an allowlist of proxy IP ranges, then change getClientIp to return
cf-connecting-ip if present, and only parse x-real-ip/x-forwarded-for when
TRUST_FORWARDED_HEADERS is true or the request originates from a known proxy IP;
update references to the getClientIp function to rely on the new behavior.

Comment on lines +28 to +42
process.env.CSRF_SECRET = 'test_csrf_secret';

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' }),
});

expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe('CSRF_MISSING');
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 | 🟡 Minor

Restore CSRF_SECRET after the test to avoid cross-test leakage.
Line 28 mutates process.env without cleanup, which can taint other tests in the same worker.

🧹 Proposed fix to restore process.env.CSRF_SECRET
-    process.env.CSRF_SECRET = 'test_csrf_secret';
-
-    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' }),
-    });
-
-    expect(res.status).toBe(403);
-    const body = await res.json();
-    expect(body.code).toBe('CSRF_MISSING');
+    const prevSecret = process.env.CSRF_SECRET;
+    process.env.CSRF_SECRET = 'test_csrf_secret';
+
+    try {
+      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' }),
+      });
+
+      expect(res.status).toBe(403);
+      const body = await res.json();
+      expect(body.code).toBe('CSRF_MISSING');
+    } finally {
+      if (prevSecret === undefined) {
+        delete process.env.CSRF_SECRET;
+      } else {
+        process.env.CSRF_SECRET = prevSecret;
+      }
+    }
📝 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
process.env.CSRF_SECRET = 'test_csrf_secret';
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' }),
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe('CSRF_MISSING');
const prevSecret = process.env.CSRF_SECRET;
process.env.CSRF_SECRET = 'test_csrf_secret';
try {
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' }),
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe('CSRF_MISSING');
} finally {
if (prevSecret === undefined) {
delete process.env.CSRF_SECRET;
} else {
process.env.CSRF_SECRET = prevSecret;
}
}
🤖 Prompt for AI Agents
In `@frontend/lib/tests/admin-csrf-contract.test.ts` around lines 28 - 42, The
test sets process.env.CSRF_SECRET directly which can leak into other tests;
capture the original value of process.env.CSRF_SECRET before mutating it and
restore it after the test (either in an afterEach hook or a finally block around
the test) so subsequent tests are unaffected; locate the mutation in
admin-csrf-contract.test.ts where process.env.CSRF_SECRET = 'test_csrf_secret'
and ensure restoration of the original value around the call to patchStatus and
assertions.

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