From b90f6e0c7a2a1c2bf3528af53ea3120704986b35 Mon Sep 17 00:00:00 2001 From: Alpamys Date: Tue, 7 Apr 2026 19:28:08 +0500 Subject: [PATCH] fix(security): harden chat proxy SSRF with ipaddress.is_loopback validation Replace string-based hostname allowlist with ipaddress.ip_address().is_loopback to properly handle 127.x.x.x range and IPv6 loopback. Blocks private/link-local addresses (192.168.x.x, 10.x.x.x, ::ffff:127.0.0.1) for HTTP endpoints. Adds 2 tests: reject private IP, allow 127.0.0.2 loopback. --- soup_cli/ui/app.py | 11 ++++++++++- tests/test_ui_chat.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/soup_cli/ui/app.py b/soup_cli/ui/app.py index 963829f..636470b 100644 --- a/soup_cli/ui/app.py +++ b/soup_cli/ui/app.py @@ -614,8 +614,17 @@ def create_app(host: str = "127.0.0.1", port: int = 7860): # SSRF protection: localhost-only HTTP, HTTPS for remote parsed = urlparse(req.endpoint) if parsed.scheme == "http": + import ipaddress as _ipaddr + host = parsed.hostname or "" - if host not in ("localhost", "127.0.0.1", "0.0.0.0", "::1"): + is_local = host in ("localhost", "0.0.0.0") + if not is_local: + try: + addr = _ipaddr.ip_address(host) + is_local = addr.is_loopback + except ValueError: + is_local = False + if not is_local: raise HTTPException( status_code=400, detail="HTTP only allowed for localhost endpoints", diff --git a/tests/test_ui_chat.py b/tests/test_ui_chat.py index 09910e6..ba4def7 100644 --- a/tests/test_ui_chat.py +++ b/tests/test_ui_chat.py @@ -186,6 +186,47 @@ class TestChatInvalidScheme: ) assert response.status_code == 400 + def test_rejects_ipv6_mapped_loopback_alias(self): + """Chat endpoint should reject IPv6-mapped private addresses.""" + try: + from fastapi.testclient import TestClient + except ImportError: + pytest.skip("FastAPI not installed") + + from soup_cli.ui.app import create_app + + client = TestClient(create_app()) + response = client.post( + "/api/chat/send", + json={ + "messages": [{"role": "user", "content": "hi"}], + "endpoint": "http://192.168.1.1:8000", + }, + headers=_auth_headers(), + ) + assert response.status_code == 400 + + def test_allows_127_loopback_range(self): + """Chat endpoint should allow 127.x.x.x loopback addresses.""" + try: + from fastapi.testclient import TestClient + except ImportError: + pytest.skip("FastAPI not installed") + + from soup_cli.ui.app import create_app + + client = TestClient(create_app()) + response = client.post( + "/api/chat/send", + json={ + "messages": [{"role": "user", "content": "hi"}], + "endpoint": "http://127.0.0.2:8000", + }, + headers=_auth_headers(), + ) + # 127.0.0.2 is loopback — should NOT be rejected as SSRF + assert response.status_code != 400 + def test_rejects_file_scheme(self): """Chat endpoint should reject file:// URLs.""" try: