mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 17:22:25 +01:00
459 lines
16 KiB
Python
459 lines
16 KiB
Python
import asyncio
|
|
import http.server
|
|
import json
|
|
import threading
|
|
from typing import List, Tuple
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from starlette.requests import Request
|
|
from starlette.testclient import TestClient
|
|
|
|
from core.database import SessionLocal, ModelEndpoint
|
|
from routes.editor_agent_routes import setup_editor_agent_routes
|
|
import src.editor_agent_model as eam
|
|
|
|
|
|
class TrackingRequestHandler(http.server.BaseHTTPRequestHandler):
|
|
received_requests: List[Tuple[str, str, dict]] = []
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("content-length", 0))
|
|
body = self.rfile.read(length) if length > 0 else b""
|
|
try:
|
|
body_json = json.loads(body.decode("utf-8"))
|
|
except Exception:
|
|
body_json = {}
|
|
TrackingRequestHandler.received_requests.append((self.command, self.path, body_json))
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(b'{"choices": [{"message": {"content": "ATTACKER_PLAN"}}]}')
|
|
|
|
def log_message(self, format, *args):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def local_listener():
|
|
TrackingRequestHandler.received_requests = []
|
|
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingRequestHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield f"http://127.0.0.1:{port}/v1"
|
|
server.shutdown()
|
|
server.server_close()
|
|
|
|
|
|
def create_mock_request(path: str, body: dict, user: str = "testuser", is_admin: bool = False):
|
|
scope = {
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": path,
|
|
"headers": [(b"content-type", b"application/json")],
|
|
"app": MagicMock(),
|
|
}
|
|
req = Request(scope)
|
|
auth_mgr = MagicMock()
|
|
auth_mgr.is_admin.return_value = is_admin
|
|
auth_mgr.is_configured = True
|
|
req.app.state.auth_manager = auth_mgr
|
|
|
|
async def mock_json():
|
|
return body
|
|
|
|
req.json = mock_json
|
|
return req
|
|
|
|
|
|
SYNTHETIC_SCREENSHOT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
|
|
|
|
|
def test_ssrf_arbitrary_listener_receives_zero_requests(local_listener, monkeypatch):
|
|
"""Prove attacker-supplied agent_model URL receives 0 requests on planner routes."""
|
|
router = setup_editor_agent_routes()
|
|
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
|
|
agent_route = next(r for r in router.routes if r.path == "/api/editor/agent")
|
|
|
|
async def _fake_wait(fut, timeout=None, **kwargs):
|
|
return {"ok": True, "result": {"width": 100, "height": 100}}
|
|
|
|
monkeypatch.setattr("routes.editor_agent_routes.asyncio.wait_for", _fake_wait)
|
|
|
|
payload = {
|
|
"prompt": "make it brighter",
|
|
"screenshot": SYNTHETIC_SCREENSHOT,
|
|
"agent_model": f"{local_listener}::attacker-model",
|
|
}
|
|
|
|
# Test /api/editor/plan
|
|
req_plan = create_mock_request("/api/editor/plan", payload, user="alice", is_admin=False)
|
|
with patch("routes.editor_agent_routes._user", return_value="alice"), \
|
|
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
|
|
resp_plan = asyncio.run(plan_route.endpoint(req_plan))
|
|
assert resp_plan.status_code == 200
|
|
assert len(TrackingRequestHandler.received_requests) == 0
|
|
|
|
# Test /api/editor/agent
|
|
req_agent = create_mock_request("/api/editor/agent", payload, user="alice", is_admin=False)
|
|
with patch("routes.editor_agent_routes._user", return_value="alice"), \
|
|
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
|
|
resp_agent = asyncio.run(agent_route.endpoint(req_agent))
|
|
assert resp_agent.status_code == 200
|
|
assert len(TrackingRequestHandler.received_requests) == 0
|
|
|
|
|
|
def test_ssrf_admin_cannot_target_arbitrary_url(local_listener):
|
|
"""Admin access must not grant arbitrary outbound network destinations."""
|
|
payload = {
|
|
"prompt": "make it brighter",
|
|
"screenshot": SYNTHETIC_SCREENSHOT,
|
|
"agent_model": f"{local_listener}::attacker-model",
|
|
}
|
|
router = setup_editor_agent_routes()
|
|
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
|
|
|
|
req = create_mock_request("/api/editor/plan", payload, user="admin_user", is_admin=True)
|
|
with patch("routes.editor_agent_routes._user", return_value="admin_user"), \
|
|
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True):
|
|
resp = asyncio.run(plan_route.endpoint(req))
|
|
assert resp.status_code == 200
|
|
assert len(TrackingRequestHandler.received_requests) == 0
|
|
|
|
|
|
def test_candidates_cloud_metadata_rejected():
|
|
"""Cloud metadata destination is rejected fail-closed."""
|
|
cands = eam._candidates("user1", "http://169.254.169.254/latest/meta-data::cloud-meta")
|
|
assert cands == []
|
|
|
|
|
|
def test_candidates_foreign_owned_endpoint_rejected():
|
|
"""Foreign-owned endpoint ID fails closed for a different user."""
|
|
db = SessionLocal()
|
|
try:
|
|
ep = ModelEndpoint(
|
|
id="ep-foreign-test",
|
|
name="Foreign Cluster",
|
|
base_url="http://192.168.1.100:8000/v1",
|
|
owner="victim_user",
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["victim-model"]),
|
|
)
|
|
db.merge(ep)
|
|
db.commit()
|
|
|
|
# Attacker user query
|
|
cands = eam._candidates("attacker_user", "ep-foreign-test::victim-model")
|
|
assert cands == []
|
|
finally:
|
|
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-foreign-test").delete()
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
def test_candidates_disabled_endpoint_rejected():
|
|
"""Disabled endpoint ID fails closed."""
|
|
db = SessionLocal()
|
|
try:
|
|
ep = ModelEndpoint(
|
|
id="ep-disabled-test",
|
|
name="Disabled Cluster",
|
|
base_url="http://192.168.1.100:8000/v1",
|
|
owner="user1",
|
|
is_enabled=False,
|
|
cached_models=json.dumps(["some-model"]),
|
|
)
|
|
db.merge(ep)
|
|
db.commit()
|
|
|
|
cands = eam._candidates("user1", "ep-disabled-test::some-model")
|
|
assert cands == []
|
|
finally:
|
|
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-disabled-test").delete()
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
def test_candidates_nonexistent_endpoint_rejected():
|
|
"""Nonexistent endpoint ID fails closed."""
|
|
cands = eam._candidates("user1", "ep-nonexistent-9999::model1")
|
|
assert cands == []
|
|
|
|
|
|
def test_candidates_forged_model_id_rejected():
|
|
"""Forged model ID on an authorized endpoint fails closed."""
|
|
db = SessionLocal()
|
|
try:
|
|
ep = ModelEndpoint(
|
|
id="ep-auth-test",
|
|
name="User Cluster",
|
|
base_url="http://127.0.0.1:9099/v1",
|
|
owner="user1",
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["real-model"]),
|
|
)
|
|
db.merge(ep)
|
|
db.commit()
|
|
|
|
# Legitimate model passes
|
|
valid_cands = eam._candidates("user1", "ep-auth-test::real-model")
|
|
assert len(valid_cands) >= 1
|
|
assert valid_cands[0]["model"] == "real-model"
|
|
|
|
# Forged model fails closed
|
|
forged_cands = eam._candidates("user1", "ep-auth-test::forged-attacker-model")
|
|
assert forged_cands == []
|
|
finally:
|
|
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-auth-test").delete()
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
def test_candidates_duplicate_model_ids_scoped_by_endpoint():
|
|
"""Duplicate model IDs across different endpoints do not collide or cross providers."""
|
|
db = SessionLocal()
|
|
try:
|
|
ep1 = ModelEndpoint(
|
|
id="ep-provider-1",
|
|
name="Provider 1",
|
|
base_url="http://127.0.0.1:9001/v1",
|
|
owner="user1",
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["shared-model-name"]),
|
|
)
|
|
ep2 = ModelEndpoint(
|
|
id="ep-provider-2",
|
|
name="Provider 2",
|
|
base_url="http://127.0.0.1:9002/v1",
|
|
owner="user1",
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["shared-model-name"]),
|
|
)
|
|
db.merge(ep1)
|
|
db.merge(ep2)
|
|
db.commit()
|
|
|
|
cands1 = eam._candidates("user1", "ep-provider-1::shared-model-name")
|
|
assert len(cands1) >= 1
|
|
assert cands1[0]["endpoint_id"] == "ep-provider-1"
|
|
assert "9001" in cands1[0]["base"]
|
|
|
|
cands2 = eam._candidates("user1", "ep-provider-2::shared-model-name")
|
|
assert len(cands2) >= 1
|
|
assert cands2[0]["endpoint_id"] == "ep-provider-2"
|
|
assert "9002" in cands2[0]["base"]
|
|
finally:
|
|
db.query(ModelEndpoint).filter(ModelEndpoint.id.in_(["ep-provider-1", "ep-provider-2"])).delete()
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
def test_candidates_authorized_shared_endpoint():
|
|
"""Shared endpoint (owner is NULL) is accessible to authenticated user."""
|
|
db = SessionLocal()
|
|
try:
|
|
ep = ModelEndpoint(
|
|
id="ep-shared-test",
|
|
name="Shared Org Cluster",
|
|
base_url="http://127.0.0.1:9005/v1",
|
|
owner=None,
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["org-planner-model"]),
|
|
)
|
|
db.merge(ep)
|
|
db.commit()
|
|
|
|
cands = eam._candidates("any_user", "ep-shared-test::org-planner-model")
|
|
assert len(cands) >= 1
|
|
assert cands[0]["endpoint_id"] == "ep-shared-test"
|
|
assert cands[0]["model"] == "org-planner-model"
|
|
finally:
|
|
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-shared-test").delete()
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
def test_candidates_legacy_raw_url_override_verified_against_registered_db():
|
|
"""Legacy raw URL format resolves only when matching a registered DB endpoint."""
|
|
db = SessionLocal()
|
|
try:
|
|
ep = ModelEndpoint(
|
|
id="ep-legacy-test",
|
|
name="Legacy Registered",
|
|
base_url="http://registered-server.internal:8000/v1",
|
|
owner="user1",
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["legacy-model"]),
|
|
)
|
|
db.merge(ep)
|
|
db.commit()
|
|
|
|
# Matching registered row resolves
|
|
cands = eam._candidates("user1", "http://registered-server.internal:8000/v1::legacy-model")
|
|
assert len(cands) >= 1
|
|
assert cands[0]["endpoint_id"] == "ep-legacy-test"
|
|
|
|
# Unregistered raw URL fails closed
|
|
unreg_cands = eam._candidates("user1", "http://unregistered-server.internal:8000/v1::legacy-model")
|
|
assert unreg_cands == []
|
|
finally:
|
|
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-legacy-test").delete()
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
def test_candidates_unauthorized_override_does_not_silently_fallback(monkeypatch):
|
|
"""Unauthorized override fails closed rather than silently falling back to defaults."""
|
|
monkeypatch.setattr(eam, "local_bases", lambda: ["http://default-local:8000/v1"])
|
|
monkeypatch.setattr(eam, "local_model", lambda: "default-local-model")
|
|
|
|
# Override is unauthorized/attacker -> MUST NOT fallback to default-local
|
|
cands = eam._candidates("user1", "http://attacker-listener.com:9999/v1::attacker-model")
|
|
assert cands == []
|
|
|
|
|
|
def test_editor_routes_unauthenticated_returns_401_when_auth_configured():
|
|
"""Unauthenticated requests must return 401 when authentication is enabled."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
app = FastAPI()
|
|
router = setup_editor_agent_routes()
|
|
app.include_router(router)
|
|
|
|
auth_mgr = MagicMock()
|
|
auth_mgr.is_configured = True
|
|
app.state.auth_manager = auth_mgr
|
|
|
|
with patch("src.auth_helpers._auth_disabled", return_value=False), \
|
|
patch("src.auth_helpers.get_current_user", return_value=None):
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
resp_plan = client.post("/api/editor/plan", json={"prompt": "draw cat"})
|
|
assert resp_plan.status_code == 401
|
|
assert resp_plan.json().get("detail") == "Not authenticated"
|
|
|
|
resp_agent = client.post("/api/editor/agent", json={"prompt": "draw cat"})
|
|
assert resp_agent.status_code == 401
|
|
assert resp_agent.json().get("detail") == "Not authenticated"
|
|
|
|
|
|
def test_candidates_empty_owner_cannot_query_foreign_private_endpoint():
|
|
"""Empty owner in multi-user mode cannot access private endpoints of other users."""
|
|
db = SessionLocal()
|
|
try:
|
|
ep = ModelEndpoint(
|
|
id="ep-private-victim",
|
|
name="Victim Private Cluster",
|
|
base_url="http://192.168.1.105:8000/v1",
|
|
owner="victim_user",
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["victim-secret-model"]),
|
|
)
|
|
db.merge(ep)
|
|
db.commit()
|
|
|
|
with patch("src.auth_helpers._auth_disabled", return_value=False):
|
|
# None owner fails closed
|
|
cands_none = eam._candidates(None, "ep-private-victim::victim-secret-model")
|
|
assert cands_none == []
|
|
|
|
# Empty string owner fails closed
|
|
cands_empty = eam._candidates("", "ep-private-victim::victim-secret-model")
|
|
assert cands_empty == []
|
|
finally:
|
|
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-private-victim").delete()
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
def test_editor_routes_api_token_without_chat_scope_returns_403():
|
|
"""Bearer API token missing 'chat' scope is rejected with 403."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
app = FastAPI()
|
|
|
|
@app.middleware("http")
|
|
async def inject_token_middleware(request, call_next):
|
|
request.state.api_token = True
|
|
request.state.api_token_scopes = ["image", "read"] # Missing "chat"
|
|
request.state.api_token_owner = "alice"
|
|
return await call_next(request)
|
|
|
|
app.include_router(setup_editor_agent_routes())
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
|
|
resp_plan = client.post("/api/editor/plan", json={"prompt": "make it pop"})
|
|
assert resp_plan.status_code == 403
|
|
assert "chat" in resp_plan.json().get("detail", "")
|
|
|
|
resp_agent = client.post("/api/editor/agent", json={"prompt": "make it pop"})
|
|
assert resp_agent.status_code == 403
|
|
assert "chat" in resp_agent.json().get("detail", "")
|
|
|
|
|
|
def test_editor_routes_api_token_with_chat_scope_resolves_owner_endpoint():
|
|
"""Bearer API token with 'chat' scope resolves the token owner's endpoints."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
alice_ep = ModelEndpoint(
|
|
id="ep-alice-token",
|
|
name="Alice Cluster",
|
|
base_url="http://127.0.0.1:9098/v1",
|
|
owner="alice",
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["alice-model"]),
|
|
)
|
|
bob_ep = ModelEndpoint(
|
|
id="ep-bob-token",
|
|
name="Bob Cluster",
|
|
base_url="http://127.0.0.1:9099/v1",
|
|
owner="bob",
|
|
is_enabled=True,
|
|
cached_models=json.dumps(["bob-model"]),
|
|
)
|
|
db.merge(alice_ep)
|
|
db.merge(bob_ep)
|
|
db.commit()
|
|
|
|
app = FastAPI()
|
|
|
|
@app.middleware("http")
|
|
async def inject_token_middleware(request, call_next):
|
|
request.state.api_token = True
|
|
request.state.api_token_scopes = ["chat"]
|
|
request.state.api_token_owner = "alice"
|
|
return await call_next(request)
|
|
|
|
app.include_router(setup_editor_agent_routes())
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
|
|
# Alice's token querying Alice's endpoint resolves
|
|
cands = eam._candidates("alice", "ep-alice-token::alice-model")
|
|
assert len(cands) == 1
|
|
assert cands[0]["endpoint_id"] == "ep-alice-token"
|
|
|
|
# Alice's token querying Bob's endpoint fails closed
|
|
cands_bob = eam._candidates("alice", "ep-bob-token::bob-model")
|
|
assert cands_bob == []
|
|
|
|
# Route-level check: calling /api/editor/plan with Alice's token targeting Bob's endpoint
|
|
# falls back and does NOT route to Bob
|
|
resp = client.post(
|
|
"/api/editor/plan",
|
|
json={"prompt": "enhance", "agent_model": "ep-bob-token::bob-model"}
|
|
)
|
|
assert resp.status_code == 200
|
|
# Should report fallback or default, never bob's endpoint
|
|
assert resp.json().get("agent_model") != "ep-bob-token::bob-model"
|
|
finally:
|
|
db.query(ModelEndpoint).filter(
|
|
ModelEndpoint.id.in_(["ep-alice-token", "ep-bob-token"])
|
|
).delete()
|
|
db.commit()
|
|
db.close()
|