Skip to content

fix(security): trust proxy hop so IP rate limits key on real client IP (#316)#328

Open
dmytrocraft wants to merge 3 commits into
mainfrom
security/316-rate-limit-untrusted-client-ip
Open

fix(security): trust proxy hop so IP rate limits key on real client IP (#316)#328
dmytrocraft wants to merge 3 commits into
mainfrom
security/316-rate-limit-untrusted-client-ip

Conversation

@dmytrocraft

@dmytrocraft dmytrocraft commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Security fix — Closes #316

Vulnerability (HIGH, CWE-307)

IP-keyed rate limiters (signin_ip, twofa_verification_ip, registration, refresh_token, email_confirmation, password_reset_confirm, oauth_social_*, user_collection, resend_confirmation, and global_api_*) build their key from Request::getClientIp(). Production config/packages/framework.yaml never set trusted_proxies/trusted_headers, so behind Caddy/FrankenPHP and the AWS App Runner load balancer getClientIp() returned REMOTE_ADDR — the LB's internal IP, identical for every external client. Every per-IP bucket therefore collapsed onto a single shared bucket: one noisy client could lock out the whole tenant (self-inflicted DoS) and no attacker could be isolated. Naively trusting the proxy without pinning the hop would instead make the key fully spoofable via client-supplied X-Forwarded-For.

Fix

  • config/packages/framework.yaml: explicitly configure trusted_proxies: '%env(TRUSTED_PROXIES)%' and trusted_headers: '%env(TRUSTED_HEADERS)%'.
  • .env: prod-safe defaults TRUSTED_PROXIES=REMOTE_ADDR (trusts a single directly-connected hop) and TRUSTED_HEADERS=x-forwarded-for (only X-Forwarded-For is trusted; host/proto/port/prefix are not). Operators override TRUSTED_PROXIES with the exact proxy/App Runner CIDR in production.
  • config/packages/test/framework.yaml: pin the same trusted_headers: 'x-forwarded-for' set.
  • New regression unit tests (ApiRateLimitTrustedProxyIpKeyTest) covering positive (trusted proxy ⇒ real client IP), spoof (untrusted client's forged X-Forwarded-For ignored), and no-trust (blanket XFF ignored) paths for both resolvers' buildIpKey().

Result: getClientIp() returns the real client IP; forged X-Forwarded-For from untrusted clients is dropped; local requests with no XFF still key on REMOTE_ADDR, preserving existing limiter and integration-test behaviour.

Local verification (one-off containers, vendor read-only)

  • PHPUnit Unit (filter RateLimit): OK (143 tests, 258 assertions)
  • PHPUnit Unit (full): OK (2262 tests, 6215 assertions)
  • Deptrac: Errors 0
  • Psalm: No errors found
  • PHP CS Fixer: 0 of 1 files

BMAD spec

specs/security-316-rate-limit-untrusted-client-ip/ (prd.md, stories.md).

🤖 Generated with Claude Code


Summary by cubic

Fixes IP-based rate limiting behind a proxy by trusting only the directly connected hop and X-Forwarded-For, so keys use the real client IP and spoofed headers are ignored. Prevents shared buckets on the load balancer IP and restores per-client isolation.

  • Bug Fixes

    • Configure framework.trusted_proxies and framework.trusted_headers via env.
    • Defaults: TRUSTED_PROXIES=REMOTE_ADDR, TRUSTED_HEADERS=x-forwarded-for.
    • Test env pins trusted_headers: 'x-forwarded-for'.
    • Add regression tests for trusted proxy resolution, spoofed XFF from untrusted clients, and no-trust fallback.
    • Address review feedback (no functional changes): reorder .env keys and fix stories.md Docker tag/code fences.
  • Migration

    • In production, set TRUSTED_PROXIES to your reverse proxy/App Runner CIDR. No other changes required.

Written for commit c6cad40. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed rate limiting to correctly identify actual client IP addresses when requests pass through reverse proxies, preventing legitimate users from being incorrectly rate limited as a single entity.
  • New Features

    • Added configurable proxy trust settings via environment variables for flexible deployment configurations.
  • Documentation

    • Added specifications and test documentation for rate-limiter proxy handling behavior.

#316)

IP-keyed rate limiters (signin_ip, twofa_verification_ip, registration,
refresh_token, email_confirmation, password_reset_confirm, oauth_social_*,
user_collection, resend_confirmation, global_api_*) built their key from
Request::getClientIp(). Production framework.yaml never configured
trusted_proxies/trusted_headers, so behind Caddy/FrankenPHP and the AWS App
Runner load balancer getClientIp() returned REMOTE_ADDR — the LB's internal
IP, identical for every external client. All per-IP buckets therefore
collapsed onto one shared bucket (CWE-307): one noisy client could lock out
the whole tenant and no attacker could be isolated.

Fix: explicitly configure framework.trusted_proxies and
framework.trusted_headers, env-driven with prod-safe defaults
(TRUSTED_PROXIES=REMOTE_ADDR trusts a single directly-connected hop,
TRUSTED_HEADERS=x-forwarded-for trusts only X-Forwarded-For). getClientIp()
now returns the real client IP; forged X-Forwarded-For from untrusted clients
is ignored and other X-Forwarded-* headers are not trusted. Operators override
TRUSTED_PROXIES with the exact proxy CIDR in production. Pinned the same header
set in the test env config and added regression unit tests covering the
positive, spoof, and no-trust paths.

Local verification:
- PHPUnit Unit (filter RateLimit): OK (143 tests, 258 assertions)
- PHPUnit Unit (full): OK (2262 tests, 6215 assertions)
- Deptrac: Errors 0
- Psalm: No errors found
- PHP CS Fixer: 0 of 1 files

Closes #316

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR addresses a security issue (#316) where rate limiters were keying on untrusted client IP, causing all requests behind App Runner's load balancer to collapse into a single rate-limit bucket. The fix configures Symfony's trusted proxy handling via environment variables and framework configuration, then verifies spoof-resistant behavior through new unit tests.

Changes

Rate limiter IP spoofing fix (#316)

Layer / File(s) Summary
Security issue specification and requirements
specs/security-316-rate-limit-untrusted-client-ip/prd.md, specs/security-316-rate-limit-untrusted-client-ip/stories.md
PRD defines the problem: rate limiters key on untrusted client IP (the load balancer IP), collapsing per-IP throttles into a shared bucket. Functional requirements specify configuration-driven trust of only one directly-connected proxy hop, X-Forwarded-For ignoring from untrusted clients, and maintenance of local request behavior. Stories map test scenarios to expected IP resolution and coverage.
Environment and framework configuration for trusted proxies
.env, config/packages/framework.yaml, config/packages/test/framework.yaml
TRUSTED_PROXIES and TRUSTED_HEADERS environment variables are added to .env with documentation. Framework production and test configs wire these into trusted_proxies and trusted_headers settings. Configuration trusts only the directly-connected reverse proxy and only X-Forwarded-For header.
Spoof-resistant rate-limit IP key tests
tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitTrustedProxyIpKeyTest.php
New unit test class verifies getClientIp() returns spoof-resistant real client IP in four scenarios: trusted proxy (sign-in and registration), forged XFF from untrusted client (ignored), and no proxy configured (REMOTE_ADDR only). Test harness snapshots/restores Symfony trusted proxy state. Helpers create authenticated requests and configure proxy trust.

Sequence Diagram

sequenceDiagram
  participant Client
  participant AppRunner as App Runner LB
  participant Caddy as FrankenPHP/Caddy
  participant Framework as Symfony Request
  participant Limiter as Rate Limiter
  
  Client->>AppRunner: Request from 203.0.113.50
  AppRunner->>Caddy: Forward request<br/>REMOTE_ADDR=10.0.0.1 (LB IP)<br/>X-Forwarded-For=203.0.113.50
  Caddy->>Framework: Pass request
  Framework->>Framework: Check trusted_proxies<br/>(configured to 10.0.0.1)
  Framework->>Framework: Extract real IP from XFF<br/>getClientIp() = 203.0.113.50
  Framework->>Limiter: Use real client IP as bucket key
  Limiter->>Limiter: Apply per-IP rate limit<br/>for 203.0.113.50 only
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 A proxy's trust, now clearly made,
No more IP spoofing charades!
Rate buckets split by real client's cries,
While forged X-Forwards fall from skies.
Configuration brings clarity's way,
Security wins the hoppy day!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing security by configuring trusted proxy settings for IP-based rate limiting to use the real client IP rather than the load balancer IP.
Linked Issues check ✅ Passed The PR fully addresses issue #316's requirements: it configures trusted_proxies and trusted_headers via environment variables, sets spoof-resistant defaults (REMOTE_ADDR, x-forwarded-for), adds regression unit tests for trusted proxy/spoofed XFF/no-trust scenarios, and ensures getClientIp() returns the real client IP while ignoring untrusted headers.
Out of Scope Changes check ✅ Passed All changes directly address issue #316: configuration files set trusted proxy settings, .env provides defaults, test config pins headers, and unit tests verify spoof-resistant behavior. Issue #39 is mentioned but contains no actual code changes in this PR.
Description check ✅ Passed The pull request description comprehensively covers the vulnerability, fix, testing, and migration steps with specific technical details and configuration requirements.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/316-rate-limit-untrusted-client-ip

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 6 files

Confidence score: 5/5

  • This looks low risk to merge: the only finding is a low-severity (3/10) documentation/story inconsistency rather than a functional code regression.
  • The most notable issue is in specs/security-316-rate-limit-untrusted-client-ip/stories.md, where the Docker tag secfix-312-php:latest appears to be a copy-paste mistake and should align with PR #316 naming.
  • Pay close attention to specs/security-316-rate-limit-untrusted-client-ip/stories.md - fix the mismatched Docker image tag to avoid confusion during verification.
Architecture diagram
sequenceDiagram
    participant Client as External Client
    participant LB as AWS App Runner LB
    participant Proxy as Caddy/FrankenPHP
    participant App as Symfony App
    participant RateLimit as Rate Limit Resolvers
    participant Cache as Rate Limit Bucket Store

    Note over Client,Cache: IP-keyed Rate Limiting Behind Trusted Proxy

    Client->>LB: HTTP request (no XFF)
    LB->>Proxy: Forward request (may inject XFF)
    Proxy->>App: Forward request with X-Forwarded-For header

    Note over App: Framework config reads TRUSTED_PROXIES & TRUSTED_HEADERS env vars
    Note over App: Default: TRUSTED_PROXIES=REMOTE_ADDR, TRUSTED_HEADERS=x-forwarded-for

    App->>App: Request::getClientIp()
    alt Trusted proxy identified (REMOTE_ADDR matches trusted CIDR)
        App->>App: Extract client IP from X-Forwarded-For header
        App-->>RateLimit: Pass real client IP (e.g., 203.0.113.42)
    else Untrusted client (REMOTE_ADDR not in trusted proxies)
        App->>App: Ignore X-Forwarded-For, use REMOTE_ADDR
        App-->>RateLimit: Pass REMOTE_ADDR (untrusted client's actual IP)
    else No trusted proxies configured
        App->>App: Ignore X-Forwarded-For entirely, use REMOTE_ADDR
        App-->>RateLimit: Pass REMOTE_ADDR
    end

    RateLimit->>RateLimit: buildIpKey() → "ip:{client IP}"

    alt Bucket not exhausted
        RateLimit->>Cache: Check rate limit bucket
        Cache-->>RateLimit: Remaining tokens
        RateLimit-->>App: Allow request (decrement bucket)
        App-->>Proxy: 200 OK response
        Proxy-->>LB: Forward response
        LB-->>Client: Response
    else Bucket exhausted
        RateLimit->>Cache: Check rate limit bucket
        Cache-->>RateLimit: No tokens remaining
        RateLimit-->>App: Rate limit exceeded
        App-->>Proxy: 429 Too Many Requests
        Proxy-->>LB: Forward 429
        LB-->>Client: 429 response
    end

    Note over Client,Cache: Edge Cases

    Client->>LB: Request with forged X-Forwarded-For: 10.0.0.1
    LB->>Proxy: Forward (may overwrite XFF or add hop)
    Proxy->>App: X-Forwarded-For: <original>,<proxy-ip>
    alt Attacker is untrusted (REMOTE_ADDR not in TRUSTED_PROXIES)
        App->>App: Discard client-supplied XFF
        App->>RateLimit: key = ip:<attacker's real IP>
    else Attacker is behind trusted proxy
        App->>App: Extract real client from XFF (before proxy hop)
        App->>RateLimit: key = ip:<real client IP from XFF>
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread specs/security-316-rate-limit-untrusted-client-ip/stories.md Outdated
@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (fe53ccb) to head (c6cad40).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #328   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          551       551           
  Lines         9541      9541           
=========================================
  Hits          9541      9541           
Flag Coverage Δ
unittests 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Signed-off-by: Vadym <kostiukdsfv@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env:
- Around line 12-13: Reorder the two environment variables so they match
dotenv-linter ordering: move TRUSTED_HEADERS to appear before TRUSTED_PROXIES in
the .env file; specifically swap the lines for TRUSTED_HEADERS and
TRUSTED_PROXIES so the key TRUSTED_HEADERS=x-forwarded-for precedes
TRUSTED_PROXIES=REMOTE_ADDR.

In `@specs/security-316-rate-limit-untrusted-client-ip/stories.md`:
- Around line 38-43: The fenced code blocks in
specs/security-316-rate-limit-untrusted-client-ip/stories.md lack a language
identifier and trigger markdownlint MD040; update each triple-backtick block
that contains the docker run invoking 'vendor/bin/phpunit --testsuite=Unit
--filter "RateLimit"' and the block that runs 'vendor/bin/php-cs-fixer' (and the
block with the Unit suite docker run) to use a language identifier (e.g.,
```bash) so the command blocks are marked as bash; ensure you add the identifier
to all occurrences (including the blocks referencing
tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitTrustedProxyIpKeyTest.php).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 698fdc32-9055-4fc1-8b81-f3bd460e7dc2

📥 Commits

Reviewing files that changed from the base of the PR and between fe53ccb and 8618bef.

📒 Files selected for processing (6)
  • .env
  • config/packages/framework.yaml
  • config/packages/test/framework.yaml
  • specs/security-316-rate-limit-untrusted-client-ip/prd.md
  • specs/security-316-rate-limit-untrusted-client-ip/stories.md
  • tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitTrustedProxyIpKeyTest.php

Comment thread .env Outdated
Comment thread specs/security-316-rate-limit-untrusted-client-ip/stories.md Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

- stories.md: correct docker image tag secfix-312-php -> secfix-316-php
  (copy-paste from PR #312; align with this PR's secfix-316 worktree)
- stories.md: add bash language id to fenced code blocks (markdownlint MD040)
- .env: order TRUSTED_HEADERS before TRUSTED_PROXIES (dotenv-linter UnorderedKey)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dmytrocraft

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 2 files (changes from recent commits).

Re-trigger cubic

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.

[Security] high: Rate limiters key on untrusted client IP (no trusted_proxies), collapsing anti-automation onto the load-balancer IP

2 participants