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.
This commit is contained in:
Alpamys 2026-04-07 19:28:08 +05:00
parent 69c4ee6a29
commit b90f6e0c7a
2 changed files with 51 additions and 1 deletions

View File

@ -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",

View File

@ -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: