diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 8af0d638..5ce2df06 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -129,32 +129,46 @@ def _parse_trusted_proxies() -> Tuple[set, list]: _TRUSTED_PROXY_IPS, _TRUSTED_PROXY_NETS = _parse_trusted_proxies() +def _is_trusted_proxy_ip(ip_text: str) -> bool: + """Return True if an IP belongs to configured trusted proxies.""" + if not ip_text: + return False + try: + ip_obj = ipaddress.ip_address(ip_text) + if ip_text in _TRUSTED_PROXY_IPS: + return True + for net in _TRUSTED_PROXY_NETS: + if ip_obj in net: + return True + return False + except Exception: + return ip_text in _TRUSTED_PROXY_IPS + + def client_ip_from_request(req) -> str: remote = (req.remote_addr or "").strip() if not remote: return "" - trusted = False - try: - ip = ipaddress.ip_address(remote) - if remote in _TRUSTED_PROXY_IPS: - trusted = True - else: - for net in _TRUSTED_PROXY_NETS: - if ip in net: - trusted = True - break - except Exception: - trusted = remote in _TRUSTED_PROXY_IPS - - if not trusted: + if not _is_trusted_proxy_ip(remote): return remote xff = (req.headers.get("X-Forwarded-For", "") or "").strip() if not xff: return remote - first = xff.split(",")[0].strip() - return first or remote + + # Walk right-to-left to resist client-controlled header injection. + # Proxies append their observed client to the right side. + hops = [h.strip() for h in xff.split(",") if h.strip()] + hops.append(remote) + for hop in reversed(hops): + try: + ipaddress.ip_address(hop) + except Exception: + continue + if not _is_trusted_proxy_ip(hop): + return hop + return remote # Register Hall of Rust blueprint (tables initialized after DB_PATH is set) try: diff --git a/tests/test_api.py b/tests/test_api.py index 305fe09a..053897de 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,6 +4,7 @@ from unittest.mock import patch, MagicMock import sys from pathlib import Path +from types import SimpleNamespace # Modules are pre-loaded in conftest.py integrated_node = sys.modules["integrated_node"] @@ -67,3 +68,29 @@ def test_api_miners(client): assert data[0]['miner'] == "addr1" assert data[0]['hardware_type'] == "PowerPC G4 (Vintage)" assert data[0]['antiquity_multiplier'] == 2.5 + + +def test_client_ip_from_request_ignores_leftmost_xff_spoof(monkeypatch): + """Trusted-proxy mode should ignore client-injected left-most XFF entries.""" + monkeypatch.setattr(integrated_node, "_TRUSTED_PROXY_IPS", {"127.0.0.1"}) + monkeypatch.setattr(integrated_node, "_TRUSTED_PROXY_NETS", []) + + req = SimpleNamespace( + remote_addr="127.0.0.1", + headers={"X-Forwarded-For": "203.0.113.250, 198.51.100.77"}, + ) + + assert integrated_node.client_ip_from_request(req) == "198.51.100.77" + + +def test_client_ip_from_request_untrusted_remote_uses_remote_addr(monkeypatch): + """When not behind a trusted proxy, XFF must be ignored.""" + monkeypatch.setattr(integrated_node, "_TRUSTED_PROXY_IPS", {"127.0.0.1"}) + monkeypatch.setattr(integrated_node, "_TRUSTED_PROXY_NETS", []) + + req = SimpleNamespace( + remote_addr="198.51.100.12", + headers={"X-Forwarded-For": "203.0.113.250"}, + ) + + assert integrated_node.client_ip_from_request(req) == "198.51.100.12"