mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-24 07:42:21 +02:00
The manage_endpoints agent tool accepts arbitrary user-supplied base_urls and writes them to the database without calling check_outbound_url(), while the HTTP route (POST /api/model-endpoints) enforces full SSRF hardening. A prompt-injection payload smuggled into a skill, note, fetched page, or email could instruct the model to register a cloud-metadata or internal-network endpoint via this tool path. Add the same check_outbound_url() guard used by the HTTP route to the agent tool's 'add' action. Also sanitize the error message in the except block to avoid leaking raw exception text. Add 8 regression tests covering: cloud metadata, non-HTTP schemes, loopback (local-first), strict mode, safe URLs, empty URLs, gopher scheme, and error message sanitization. Co-authored-by: isharak7m <192635824+isharak7m@users.noreply.github.com>
258 lines
8.2 KiB
Python
258 lines
8.2 KiB
Python
"""Regression tests for SSRF hardening on the manage_endpoints agent tool.
|
|
|
|
Verifies that the do_manage_endpoints tool function rejects user-supplied URLs
|
|
that resolve to link-local (cloud metadata), non-HTTP schemes, or (when
|
|
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses -- applying
|
|
the shared outbound-URL policy in check_outbound_url(). The HTTP route
|
|
(POST /api/model-endpoints) receives the same guard in #6316.
|
|
|
|
The agent tool path bypasses the HTTP route entirely, so it needs its own
|
|
validation. A prompt-injection payload smuggled into a skill, note, fetched
|
|
page, or email could instruct the model to register a cloud-metadata or
|
|
internal-network endpoint via this tool.
|
|
"""
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
import src.url_safety as _url_safety
|
|
|
|
_real_check_outbound_url = _url_safety.check_outbound_url
|
|
|
|
|
|
def _fake_resolver(host):
|
|
"""Resolver that returns deterministic IPs without real DNS."""
|
|
_HOST_MAP = {
|
|
"169.254.169.254": ["169.254.169.254"],
|
|
"metadata.google.internal": ["169.254.169.254"],
|
|
"localhost": ["127.0.0.1"],
|
|
"127.0.0.1": ["127.0.0.1"],
|
|
"0.0.0.0": ["0.0.0.0"],
|
|
"example.com": ["93.184.216.34"],
|
|
"192.168.1.1": ["192.168.1.1"],
|
|
"10.0.0.1": ["10.0.0.1"],
|
|
"api.openai.com": ["104.18.6.192"],
|
|
}
|
|
if host in _HOST_MAP:
|
|
return _HOST_MAP[host]
|
|
try:
|
|
ipaddress.ip_address(host)
|
|
return [host]
|
|
except ValueError:
|
|
pass
|
|
raise OSError(f"Name or service not known: {host}")
|
|
|
|
|
|
def _check_with_fake_resolver(url, *, block_private=False, resolver=None):
|
|
"""Call the real check_outbound_url with our fake resolver."""
|
|
return _real_check_outbound_url(url, block_private=block_private, resolver=_fake_resolver)
|
|
|
|
|
|
def _make_content(base_url, *, name="", api_key=""):
|
|
"""Build a JSON content string for the manage_endpoints 'add' action."""
|
|
return json.dumps({
|
|
"action": "add",
|
|
"name": name,
|
|
"base_url": base_url,
|
|
"api_key": api_key,
|
|
})
|
|
|
|
|
|
class _FakeQuery:
|
|
def __init__(self, model):
|
|
self._model = model
|
|
|
|
def filter(self, *args):
|
|
return self
|
|
|
|
def first(self):
|
|
return None
|
|
|
|
def all(self):
|
|
return []
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self):
|
|
self.added = []
|
|
self.committed = False
|
|
|
|
def query(self, model):
|
|
return _FakeQuery(model)
|
|
|
|
def add(self, obj):
|
|
self.added.append(obj)
|
|
|
|
def commit(self):
|
|
self.committed = True
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _FakeModelEndpoint:
|
|
def __init__(self, **kwargs):
|
|
for k, v in kwargs.items():
|
|
setattr(self, k, v)
|
|
|
|
|
|
def _setup_mocks(monkeypatch, db):
|
|
"""Set up faked core.database and patched check_outbound_url."""
|
|
fake_core_db = types.ModuleType("core.database")
|
|
fake_core_db.SessionLocal = lambda: db
|
|
fake_core_db.ModelEndpoint = _FakeModelEndpoint
|
|
monkeypatch.setitem(sys.modules, "core.database", fake_core_db)
|
|
|
|
monkeypatch.setattr(_url_safety, "check_outbound_url", _check_with_fake_resolver)
|
|
|
|
if "src.agent_tools.admin_tools" in sys.modules:
|
|
monkeypatch.delitem(sys.modules, "src.agent_tools.admin_tools", raising=False)
|
|
|
|
|
|
def _get_do_manage_endpoints():
|
|
"""Import and return do_manage_endpoints from the reloaded admin_tools."""
|
|
from src.agent_tools.admin_tools import do_manage_endpoints
|
|
return do_manage_endpoints
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_metadata_blocked(monkeypatch):
|
|
"""Cloud metadata IP (169.254.169.254) must be rejected."""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("http://169.254.169.254/latest/meta-data/")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "Rejected" in result["error"]
|
|
assert len(db.added) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_http_scheme_blocked(monkeypatch):
|
|
"""Non-HTTP schemes (file://, gopher://) must be rejected."""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("file:///etc/passwd")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "Rejected" in result["error"]
|
|
assert len(db.added) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loopback_accepted_by_default(monkeypatch):
|
|
"""Loopback addresses are accepted by default (local-first design).
|
|
|
|
The agent tool uses check_outbound_url directly, which allows
|
|
loopback by default -- this is correct for local model endpoints
|
|
(Ollama, llama.cpp, etc.). Loopback is only blocked in strict mode.
|
|
"""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("http://localhost:11434/v1")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "Added endpoint" in result["response"]
|
|
assert len(db.added) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strict_mode_blocks_private(monkeypatch):
|
|
"""MODELENDPOINT_BLOCK_PRIVATE_IPS=true blocks private IPs."""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
monkeypatch.setenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "true")
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("http://192.168.1.1:8080/v1")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "Rejected" in result["error"]
|
|
assert len(db.added) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_safe_url_accepted(monkeypatch):
|
|
"""A safe public URL should be accepted and stored."""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("https://api.openai.com/v1", name="OpenAI")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "Added endpoint" in result["response"]
|
|
assert len(db.added) == 1
|
|
assert db.committed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_url_rejected(monkeypatch):
|
|
"""An empty base_url must be rejected before SSRF check."""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "base_url is required" in result["error"]
|
|
assert len(db.added) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gopher_scheme_blocked(monkeypatch):
|
|
"""gopher:// scheme must be rejected."""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("gopher://evil:6379/secret")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "Rejected" in result["error"]
|
|
assert len(db.added) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_not_leaked(monkeypatch):
|
|
"""Error messages must not leak raw exception text."""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("http://169.254.169.254/latest/meta-data/")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "Rejected" in result["error"]
|
|
assert "Traceback" not in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ipv6_loopback_accepted_by_default(monkeypatch):
|
|
"""::1 must be treated as loopback (accepted local-first), not reserved.
|
|
|
|
Proves this branch carries the dfd1f7e7 url_safety fix after rebasing
|
|
onto current dev: the pre-fix guard checked is_reserved before is_loopback
|
|
and rejected ::1 even in default local-first mode.
|
|
"""
|
|
db = _FakeDB()
|
|
_setup_mocks(monkeypatch, db)
|
|
do_manage_endpoints = _get_do_manage_endpoints()
|
|
content = _make_content("http://[::1]:11434/v1")
|
|
result = await do_manage_endpoints(content, owner="admin")
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "Added endpoint" in result["response"]
|
|
assert len(db.added) == 1
|