Skip to content

fix(security): join repeated X-Forwarded-For lines before taking the last hop - #14425

Merged
erichare merged 1 commit into
release-1.11.3from
security/xff-multiline-1113
Aug 7, 2026
Merged

fix(security): join repeated X-Forwarded-For lines before taking the last hop#14425
erichare merged 1 commit into
release-1.11.3from
security/xff-multiline-1113

Conversation

@erichare

@erichare erichare commented Aug 5, 2026

Copy link
Copy Markdown
Member

Context

While confirming GHSA-4f6c-2vvp-gw82 / GHSA-qvvj-g573-9638 (MCP installer trusting client-supplied X-Forwarded-For) against release-1.11.3, I found the reported bypass is already fixed1641b28f33 (#13530, 2026-07-13) is contained in v1.11.0/v1.11.1/v1.11.2, and release-1.10.3 has it via the separate backport 94859df33a (#14071). The advisory PoC no longer reproduces.

This PR fixes a residual variant of the same bypass that is still reachable on release-1.11.3.

The bug

Both client-IP resolvers read the forwarded chain with Headers.get("X-Forwarded-For"):

  • langflow.api.v1.mcp_projects.get_client_ip
  • langflow.services.rate_limit.service.get_client_ip

Starlette's Headers.get returns only the first matching header line; it does not join repeated occurrences the way uvicorn does. Proxies that append their own X-Forwarded-For line rather than extending the client's — HAProxy's option forwardfor does this by default — therefore leave the caller's line as the one we parse. The "rightmost entry is the trusted proxy's last hop, which a client cannot forge" invariant then resolves to an attacker-chosen value.

Concretely, with rate_limit_trust_proxy=True:

X-Forwarded-For: 127.0.0.1        <- attacker's line, sent first
X-Forwarded-For: 203.0.113.7      <- line appended by the trusted proxy

Headers.get returns "127.0.0.1", its rightmost entry is 127.0.0.1, and is_local_ip passes — so a remote caller clears the local-only gate on POST /api/v1/mcp/project/{project_id}/install, which writes MCP client config (~/.cursor/mcp.json and friends) to the host filesystem. That is the impact GHSA-4f6c-2vvp-gw82 describes, reached through a differently-shaped header.

The same parsing feeds rate-limit bucket keys for login, public build, and public workflow endpoints, so a caller can also pin or rotate their own bucket.

nginx's $proxy_add_x_forwarded_for produces a single joined line and was never affected.

Scope

Default deployments are not affected. rate_limit_trust_proxy defaults to False, and neither resolver reads the header in that mode; forwarded_allow_ips="" additionally stops uvicorn's ProxyHeadersMiddleware from rewriting request.client. Exposure requires the operator to have opted into a trusted proxy, which is why this is materially lower severity than the original advisory.

The fix

One shared get_last_forwarded_for_hop helper that joins every occurrence of the header in order before taking the last hop, and drops empty entries so a blank header falls back to the TCP peer instead of resolving to an empty client IP. Both call sites use it; the trusted-proxy gate in mcp_projects is unchanged.

Test plan

  • Four new regression tests (repeated-line chain under trusted proxy, repeated lines ignored without the opt-in, blank-header fallback, and the rate-limit-key equivalent) fail against the pre-fix source and pass with the fix — verified by stashing only the source change and re-running.
  • The 11 pre-existing IP-extraction tests pass unchanged both before and after, so no behavior regression for single-line chains.
  • src/backend/tests/unit/api/v1/test_mcp_projects.py — 55 passed.
  • test_mcp_install_xff_trust.py + test_login_rate_limiting.py + test_rate_limit_bypass_prevention.py — 28 passed.
  • ruff check / ruff format clean; pre-commit hooks pass.

The header mocks in test_login_rate_limiting.py were plain dicts, which cannot express a repeated header and have no .getlist; they are now real starlette.datastructures.Headers, matching what the production code actually receives.

Not addressed here

Behind a same-host reverse proxy with rate_limit_trust_proxy=False, request.client.host is 127.0.0.1 for every remote user, so the local-only gate is satisfied with no spoofing at all. A TCP-peer check cannot express "this caller is on the server machine" in a proxied deployment. If install_mcp_config is meant to be a real trust boundary rather than a convenience guard, it needs a different control (an explicit setting, or a local-only bind/socket). That is a design change rather than a patch, so it is deliberately out of scope.

Summary by CodeRabbit

  • Bug Fixes

    • Improved client IP detection when requests pass through trusted proxies.
    • Correctly handles repeated or comma-separated forwarding headers, selecting the appropriate originating address.
    • Preserves direct connection fallback when forwarded address information is missing or blank.
    • Improves reliability of login rate limiting and locality checks in proxy-based deployments.
  • Tests

    • Added coverage for repeated, blank, and untrusted forwarded-header scenarios.

…last hop

Both client-IP resolvers read the forwarded chain with `Headers.get`, which
returns only the first matching header line. Proxies that append their own
`X-Forwarded-For` line rather than extending the client's (HAProxy's
`option forwardfor` among them) therefore leave the caller's line as the one
we parse, so the "rightmost entry is the trusted proxy's last hop" invariant
resolves to an attacker-chosen value.

Under `rate_limit_trust_proxy=True` this lets a remote caller send
`X-Forwarded-For: 127.0.0.1` and pass the local-only gate on
`POST /api/v1/mcp/project/{id}/install`, which writes MCP client config to the
host filesystem - the same bypass GHSA-4f6c-2vvp-gw82 reported, reachable
again through a differently-shaped header. It also lets a caller pin or rotate
their own rate-limit bucket on login and public-flow endpoints.

Join every occurrence in order before taking the last hop, and drop empty
entries so a blank header falls back to the TCP peer instead of resolving to
an empty client IP. Default deployments (`rate_limit_trust_proxy=False`) never
read the header and were not affected.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c0dd1ad-34f5-466d-a83f-eb4b6e99b3d0

📥 Commits

Reviewing files that changed from the base of the PR and between 73629e2 and a2845c5.

📒 Files selected for processing (4)
  • src/backend/base/langflow/api/v1/mcp_projects.py
  • src/backend/base/langflow/services/rate_limit/service.py
  • src/backend/tests/unit/api/v1/test_mcp_install_xff_trust.py
  • src/backend/tests/unit/test_login_rate_limiting.py

Walkthrough

The change adds shared parsing for repeated X-Forwarded-For headers. Trusted-proxy IP resolution now selects the final nonempty hop and preserves TCP peer fallback behavior. Tests cover trusted, untrusted, repeated, and blank header values.

Changes

Forwarded IP Resolution

Layer / File(s) Summary
Shared forwarded-header parsing
src/backend/base/langflow/services/rate_limit/service.py, src/backend/tests/unit/test_login_rate_limiting.py
The service combines all X-Forwarded-For values, filters empty entries, and selects the rightmost hop. Tests use Starlette Headers and cover repeated and blank headers.
MCP trusted-proxy integration
src/backend/base/langflow/api/v1/mcp_projects.py, src/backend/tests/unit/api/v1/test_mcp_install_xff_trust.py
MCP locality handling uses the shared helper. Tests cover repeated headers with and without proxy trust, plus TCP peer fallback.

Estimated code review effort: 2 (Simple) | ~15 minutes

Suggested labels: bug

Suggested reviewers: jordanrfrazier

🚥 Pre-merge checks | ✅ 8 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Test Quality And Coverage ⚠️ Warning Resolver tests cover repeated, blank, trusted, and default cases, but no test calls the changed install endpoint with a remote peer to assert its error response and blocked write. Add an async endpoint regression test with repeated XFF and a non-local peer; assert the 500 detail and that no MCP config is written. Keep a local success assertion.
✅ Passed checks (8 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main security fix for repeated X-Forwarded-For headers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Coverage For New Implementations ✅ Passed Updated test_*.py files add substantive repeated-header and blank-header regression tests for both MCP and rate-limit client-IP resolvers, including trusted and default proxy modes.
Test File Naming And Structure ✅ Passed Changed tests use test_*.py in backend unit directories, valid pytest functions/classes, descriptive names, fixtures, and positive/negative XFF edge cases; no frontend or integration tests were added.
Excessive Mock Usage Warning ✅ Passed Mocks are limited to request/settings seams; tests exercise real Starlette Headers, resolver functions, and a real TestClient integration path.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/xff-multiline-1113

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.

@github-actions github-actions Bot added the bug Something isn't working label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

✅ Test Coverage Advisor

No source changes detected without accompanying tests. Thanks for keeping coverage up! 🎉

Advisory check only — never blocks merge.

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 5, 2026
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.60%. Comparing base (73629e2) to head (a2845c5).

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##           release-1.11.3   #14425      +/-   ##
==================================================
+ Coverage           61.40%   61.60%   +0.19%     
==================================================
  Files                2398     2398              
  Lines              238302   238306       +4     
  Branches            35840    33784    -2056     
==================================================
+ Hits               146336   146810     +474     
+ Misses              90158    89688     -470     
  Partials             1808     1808              
Flag Coverage Δ
backend 68.26% <100.00%> (+0.59%) ⬆️
frontend 60.00% <ø> (+0.14%) ⬆️
lfx 60.76% <ø> (ø)

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

Files with missing lines Coverage Δ
src/backend/base/langflow/api/v1/mcp_projects.py 47.21% <100.00%> (-2.32%) ⬇️
...ckend/base/langflow/services/rate_limit/service.py 97.77% <100.00%> (+0.15%) ⬆️

... and 235 files with indirect coverage changes

🚀 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.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Frontend Unit Test Coverage Report

Coverage Summary

Lines Statements Branches Functions
Coverage: 47%
47.4% (67712/142846) 70.44% (9580/13599) 45.85% (1558/3398)

Unit Test Results

Tests Skipped Failures Errors Time
5422 0 💤 0 ❌ 0 🔥 19m 54s ⏱️

@erichare
erichare requested a review from Adam-Aghili August 5, 2026 21:03

@Adam-Aghili Adam-Aghili left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@github-actions github-actions Bot added the lgtm This PR has been approved by a maintainer label Aug 6, 2026
@erichare
erichare merged commit 425c4cb into release-1.11.3 Aug 7, 2026
231 of 233 checks passed
@erichare
erichare deleted the security/xff-multiline-1113 branch August 7, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working lgtm This PR has been approved by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants