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: