Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/backend/base/langflow/api/v1/mcp_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
from langflow.services.database.models.user.crud import get_user_by_username
from langflow.services.database.models.user.model import User
from langflow.services.deps import get_service
from langflow.services.rate_limit.service import get_last_forwarded_for_hop

# Constants
ALL_INTERFACES_HOST = "0.0.0.0" # noqa: S104
Expand Down Expand Up @@ -792,6 +793,9 @@ def get_client_ip(request: Request) -> str:
(``rate_limit_trust_proxy``) do we consult ``X-Forwarded-For``, and then we
take the rightmost entry — the last hop added by the trusted proxy, which a
client cannot forge — mirroring ``langflow.services.rate_limit.service.get_client_ip``.
Every occurrence of the header is joined first, so a proxy that appends its
own line rather than extending the client's cannot leave the attacker's line
as the one we read.

Args:
request: FastAPI Request object
Expand All @@ -802,11 +806,9 @@ def get_client_ip(request: Request) -> str:
# Only consult X-Forwarded-For when an operator has explicitly declared a
# trusted proxy; otherwise the header is attacker-controlled.
if get_settings_service().settings.rate_limit_trust_proxy:
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# Rightmost entry = last hop added by the trusted proxy (unspoofable);
# the leftmost entry is client-supplied and must never be trusted.
return forwarded_for.split(",")[-1].strip()
last_hop = get_last_forwarded_for_hop(request)
if last_hop:
return last_hop

# Default: trust only the real TCP peer.
if request.client:
Expand Down
30 changes: 24 additions & 6 deletions src/backend/base/langflow/services/rate_limit/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ def check_rate_limit(
raise RateLimitExceeded(limit_wrapper)


def get_last_forwarded_for_hop(request: Request) -> str | None:
"""Return the rightmost ``X-Forwarded-For`` entry, or None when the header is absent.

The header may legitimately arrive as several separate lines: some proxies
(HAProxy's ``option forwardfor``, for one) append their own line instead of
extending the client's. ``Headers.get`` returns only the first line, so
reading it alone would yield the rightmost entry of the *client-supplied*
line and hand an attacker control of a value the caller assumes is
unspoofable. Join every line in order, per RFC 9110 field-order semantics,
before taking the last hop.

Callers must gate this on an explicit trusted-proxy opt-in; the header is
attacker-controlled otherwise.
"""
# X-Forwarded-For format: "client, proxy1, proxy2", possibly split across lines.
chain = [ip.strip() for line in request.headers.getlist("x-forwarded-for") for ip in line.split(",")]
entries = [ip for ip in chain if ip]
# Rightmost entry = last hop added by the trusted proxy; the leftmost is client-supplied.
return entries[-1] if entries else None


def get_client_ip(request: Request) -> str:
"""Extract client IP address from request, using rightmost X-Forwarded-For entry.

Expand All @@ -118,12 +139,9 @@ def get_client_ip(request: Request) -> str:
str: Client IP address
"""
# Check X-Forwarded-For header first (for proxied requests)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# X-Forwarded-For format: "client, proxy1, proxy2"
# Take the rightmost IP (last proxy before us)
ips = [ip.strip() for ip in forwarded_for.split(",")]
return ips[-1]
last_hop = get_last_forwarded_for_hop(request)
if last_hop:
return last_hop

# Fall back to direct client IP
if request.client:
Expand Down
52 changes: 51 additions & 1 deletion src/backend/tests/unit/api/v1/test_mcp_install_xff_trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,18 @@

from langflow.api.v1 import mcp_projects
from langflow.api.v1.mcp_projects import get_client_ip, is_local_ip
from starlette.datastructures import Headers


def _request(headers: dict | None = None, host: str | None = "203.0.113.7"):
client = SimpleNamespace(host=host) if host is not None else None
return SimpleNamespace(headers=headers or {}, client=client)
return SimpleNamespace(headers=Headers(headers or {}), client=client)


def _request_raw(raw_headers: list[tuple[bytes, bytes]], host: str | None = "203.0.113.7"):
"""Build a request whose headers may repeat, which ``Headers(dict)`` cannot express."""
client = SimpleNamespace(host=host) if host is not None else None
return SimpleNamespace(headers=Headers(raw=raw_headers), client=client)


def _set_trust_proxy(monkeypatch, *, value: bool) -> None:
Expand Down Expand Up @@ -53,6 +60,49 @@ def test_trusted_proxy_uses_rightmost_xff(monkeypatch):
assert is_local_ip(ip) is False


def test_trusted_proxy_joins_repeated_xff_lines(monkeypatch):
"""A proxy that appends its own header line must not leave the client's line as the one we read.

HAProxy's ``option forwardfor`` adds a second ``X-Forwarded-For`` line rather than extending the
client's. ``Headers.get`` returns only the first line, so reading it alone would take the
rightmost entry of the attacker-supplied line — handing the attacker the value the locality gate
assumes is unspoofable.
"""
_set_trust_proxy(monkeypatch, value=True)
req = _request_raw(
[
(b"host", b"target:7860"),
(b"x-forwarded-for", b"127.0.0.1"), # attacker's own line, sent first
(b"x-forwarded-for", b"203.0.113.7"), # line appended by the trusted proxy
],
host="10.0.0.5", # TCP peer is the proxy
)
ip = get_client_ip(req)
assert ip == "203.0.113.7"
assert is_local_ip(ip) is False


def test_repeated_xff_lines_ignored_without_trusted_proxy(monkeypatch):
"""Without the trusted-proxy opt-in, repeated lines are ignored entirely."""
_set_trust_proxy(monkeypatch, value=False)
req = _request_raw(
[(b"x-forwarded-for", b"127.0.0.1"), (b"x-forwarded-for", b"127.0.0.1")],
host="203.0.113.7",
)
ip = get_client_ip(req)
assert ip == "203.0.113.7"
assert is_local_ip(ip) is False


def test_empty_xff_falls_back_to_peer(monkeypatch):
"""A blank header must not resolve to an empty client IP."""
_set_trust_proxy(monkeypatch, value=True)
req = _request(headers={"X-Forwarded-For": " "}, host="203.0.113.7")
ip = get_client_ip(req)
assert ip == "203.0.113.7"
assert is_local_ip(ip) is False


def test_no_client_falls_back_to_non_local(monkeypatch):
"""When the peer cannot be determined, fall back to a non-routable, non-local IP."""
_set_trust_proxy(monkeypatch, value=False)
Expand Down
47 changes: 41 additions & 6 deletions src/backend/tests/unit/test_login_rate_limiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from unittest.mock import Mock

import pytest
from starlette.datastructures import Headers


@pytest.fixture
Expand Down Expand Up @@ -103,7 +104,7 @@ def test_get_client_ip_from_x_forwarded_for_single(self):
from langflow.services.rate_limit.service import get_client_ip

request = Mock()
request.headers = {"X-Forwarded-For": "203.0.113.1"}
request.headers = Headers({"X-Forwarded-For": "203.0.113.1"})
request.client = Mock(host="127.0.0.1")

ip = get_client_ip(request)
Expand All @@ -115,7 +116,7 @@ def test_get_client_ip_from_x_forwarded_for_chain_uses_rightmost(self):
from langflow.services.rate_limit.service import get_client_ip

request = Mock()
request.headers = {"X-Forwarded-For": "203.0.113.1, 198.51.100.1, 192.0.2.1"}
request.headers = Headers({"X-Forwarded-For": "203.0.113.1, 198.51.100.1, 192.0.2.1"})
request.client = Mock(host="127.0.0.1")

ip = get_client_ip(request)
Expand All @@ -128,20 +129,54 @@ def test_get_client_ip_from_x_forwarded_for_with_spaces(self):
from langflow.services.rate_limit.service import get_client_ip

request = Mock()
request.headers = {"X-Forwarded-For": " 203.0.113.1 , 198.51.100.1 "}
request.headers = Headers({"X-Forwarded-For": " 203.0.113.1 , 198.51.100.1 "})
request.client = Mock(host="127.0.0.1")

ip = get_client_ip(request)

# Should use rightmost IP, stripped of whitespace
assert ip == "198.51.100.1"

def test_get_client_ip_joins_repeated_x_forwarded_for_lines(self):
"""Repeated X-Forwarded-For lines must be joined before taking the rightmost entry.

Some proxies append their own header line instead of extending the client's. Reading only
the first line would key the rate limiter on an attacker-chosen value, letting a caller pin
or rotate their own bucket.
"""
from langflow.services.rate_limit.service import get_client_ip

request = Mock()
request.headers = Headers(
raw=[
(b"x-forwarded-for", b"203.0.113.1"), # client-supplied line
(b"x-forwarded-for", b"192.0.2.1"), # line appended by the trusted proxy
]
)
request.client = Mock(host="10.0.0.5")

ip = get_client_ip(request)

assert ip == "192.0.2.1"

def test_get_client_ip_ignores_blank_x_forwarded_for(self):
"""A blank header must fall back to the peer rather than returning an empty key."""
from langflow.services.rate_limit.service import get_client_ip

request = Mock()
request.headers = Headers({"X-Forwarded-For": " , "})
request.client = Mock(host="192.168.1.100")

ip = get_client_ip(request)

assert ip == "192.168.1.100"

def test_get_client_ip_from_direct_connection(self):
"""Test IP extraction from direct client connection (no proxy)."""
from langflow.services.rate_limit.service import get_client_ip

request = Mock()
request.headers = {}
request.headers = Headers({})
request.client = Mock(host="192.168.1.100")

ip = get_client_ip(request)
Expand All @@ -153,7 +188,7 @@ def test_get_client_ip_fallback_to_unknown(self):
from langflow.services.rate_limit.service import get_client_ip

request = Mock()
request.headers = {}
request.headers = Headers({})
request.client = None

ip = get_client_ip(request)
Expand All @@ -165,7 +200,7 @@ def test_get_client_ip_prefers_x_forwarded_for(self):
from langflow.services.rate_limit.service import get_client_ip

request = Mock()
request.headers = {"X-Forwarded-For": "203.0.113.1"}
request.headers = Headers({"X-Forwarded-For": "203.0.113.1"})
request.client = Mock(host="127.0.0.1")

ip = get_client_ip(request)
Expand Down
Loading