fix(security): close API token agent authorization gaps

This commit is contained in:
RaresKeY
2026-08-28 18:07:40 +00:00
parent aeff329c05
commit 9150a453b4
20 changed files with 907 additions and 114 deletions
+51 -6
View File
@@ -327,6 +327,53 @@ async def test_api_chat_direct_base_url_allows_mocked_public_endpoint(monkeypatc
assert session_manager.created[0]["endpoint_url"] == "https://api.example.com/v1/chat/completions"
@pytest.mark.asyncio
async def test_api_chat_ownerless_token_cannot_use_direct_api_key(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
_install_sync_chat_stubs(monkeypatch)
session_manager = _SessionManager()
sync_chat = _sync_chat_endpoint(webhook_routes, session_manager)
body = types.SimpleNamespace(
message="hello",
api_key="test-key",
base_url="https://api.example.com/v1",
model="test-model",
provider=None,
session=None,
)
with pytest.raises(webhook_routes.HTTPException) as exc:
await sync_chat(_Request(owner=None), body)
assert exc.value.status_code == 403
assert session_manager.created == []
@pytest.mark.asyncio
async def test_api_chat_ownerless_token_cannot_use_configured_fallback(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
_install_sync_chat_stubs(monkeypatch)
db = _DB([_Endpoint(owner=None, base_url="http://localhost:11434/v1", api_key="shared-key")])
monkeypatch.setattr(webhook_routes, "ModelEndpoint", _ModelEndpoint)
monkeypatch.setattr(webhook_routes, "SessionLocal", lambda: db)
session_manager = _SessionManager()
sync_chat = _sync_chat_endpoint(webhook_routes, session_manager)
body = types.SimpleNamespace(
message="hello",
api_key=None,
base_url=None,
model="local-model",
provider=None,
session=None,
)
with pytest.raises(webhook_routes.HTTPException) as exc:
await sync_chat(_Request(owner=None), body)
assert exc.value.status_code == 403
assert session_manager.created == []
def test_api_chat_fallback_endpoint_selection_for_owned_token(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
rows = [
@@ -345,7 +392,7 @@ def test_api_chat_fallback_endpoint_selection_for_owned_token(monkeypatch):
assert selected.created_at == 2
def test_api_chat_fallback_without_owner_uses_shared_only(monkeypatch):
def test_api_chat_fallback_without_owner_is_not_selectable(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
rows = [
_Endpoint(owner="alice", created_at=0),
@@ -357,9 +404,7 @@ def test_api_chat_fallback_without_owner_uses_shared_only(monkeypatch):
selected = webhook_routes._select_api_chat_fallback_endpoint(_DB(rows), None)
assert selected.owner is None
assert selected.is_enabled is True
assert selected.created_at == 2
assert selected is None
@pytest.mark.asyncio
@@ -367,7 +412,7 @@ async def test_api_chat_fallback_trusts_configured_local_endpoint(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
_install_sync_chat_stubs(monkeypatch)
local_endpoint = _Endpoint(
owner=None,
owner="alice",
base_url="http://localhost:11434/v1",
api_key="configured-key",
)
@@ -396,7 +441,7 @@ async def test_api_chat_fallback_trusts_configured_local_endpoint(monkeypatch):
session=None,
)
response = await sync_chat(_Request(owner=None), body)
response = await sync_chat(_Request(owner="alice"), body)
assert response["response"] == "mocked response"
assert response["model"] == "local-model"
+554
View File
@@ -0,0 +1,554 @@
"""Regression coverage for the API-token forward-fix boundary.
These tests deliberately exercise both sides of FastAPI's router dependency
boundary: real ASGI requests run router dependencies, while direct endpoint
calls must still hit the same security helper before doing work.
"""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import httpx
import pytest
from fastapi import FastAPI, HTTPException
from core.models import ChatMessage, Session
from src.auth_helpers import (
effective_user,
require_api_token_owner,
require_chat_scope,
require_interactive_request,
)
from src.message_metadata import sanitize_client_message_metadata
from src.request_models import ChatRequest
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
def _request(*, api_token=True, owner="alice", scopes=("chat",), current_user="api"):
return SimpleNamespace(
state=SimpleNamespace(
api_token=api_token,
api_token_owner=owner,
api_token_scopes=list(scopes),
current_user=current_user,
),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
headers={},
)
def test_token_scope_gate_requires_a_real_owner_and_normalizes_scope_input():
assert require_chat_scope(
_request(owner=" alice ", scopes=(" CHAT ",))
) == "alice"
assert effective_user(_request(owner=" alice ")) == "alice"
for owner in (None, "", " "):
with pytest.raises(HTTPException) as exc:
require_chat_scope(_request(owner=owner))
assert exc.value.status_code == 403
with pytest.raises(HTTPException) as exc:
require_chat_scope(_request(scopes=(["invalid"],)))
assert exc.value.status_code == 403
@pytest.mark.parametrize("owner", ["api", "internal-tool", "SYSTEM", " ", None])
def test_bearer_owner_gate_rejects_sentinels_and_ownerless_values(owner):
with pytest.raises(HTTPException) as exc:
require_api_token_owner(_request(owner=owner))
assert exc.value.status_code == 403
def test_interactive_gate_rejects_api_sentinel_even_without_bearer_flag():
with pytest.raises(HTTPException) as exc:
require_interactive_request(
_request(api_token=False, owner=None, scopes=(), current_user="api")
)
assert exc.value.status_code == 403
def test_interactive_gate_rejects_bearer_but_preserves_cookie_and_anonymous_modes():
with pytest.raises(HTTPException) as exc:
require_interactive_request(_request())
assert exc.value.status_code == 403
assert require_interactive_request(
_request(api_token=False, owner="alice", scopes=(), current_user="alice")
) == "alice"
assert require_interactive_request(
_request(api_token=False, owner=None, scopes=(), current_user=None)
) is None
class _PrincipalState:
"""Inject the same request.state fields as auth middleware, without auth."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
headers = dict(scope.get("headers") or [])
if headers.get(b"x-api-token") == b"1":
raw_scopes = headers.get(b"x-api-scopes", b"").decode()
scopes = [item for item in raw_scopes.split(",") if item]
scope["state"] = {
"api_token": True,
"api_token_owner": headers.get(b"x-api-owner", b"").decode() or None,
"api_token_scopes": scopes,
"current_user": "api",
}
else:
scope["state"] = {
"api_token": False,
"current_user": headers.get(b"x-user", b"").decode() or None,
}
await self.app(scope, receive, send)
def _client(app):
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://forward-fix.test",
)
@pytest.mark.asyncio
async def test_real_chat_and_search_routes_run_scope_dependencies(monkeypatch):
from routes.chat_routes import setup_chat_routes
from routes.search.search_routes import setup_search_routes
app = FastAPI()
app.include_router(setup_chat_routes(None, None, None, None, None, None))
app.include_router(setup_search_routes(None))
token_headers = {
"x-api-token": "1",
"x-api-owner": "alice",
"x-api-scopes": "email:read",
}
chat_headers = {**token_headers, "x-api-scopes": "chat"}
async with _client(_PrincipalState(app)) as client:
# Both standalone direct-search POST routes are capability-gated.
for path, body in (
("/api/search", {"query": "private"}),
("/api/search/query", {"query": "private", "provider": "brave"}),
):
response = await client.post(path, json=body, headers=token_headers)
assert response.status_code == 403, (path, response.text)
# The chat router's GET alias is gated too, and the detached-run
# controls add the stricter interactive-principal check after chat
# capability authorization succeeds.
assert (await client.get("/api/search?q=private", headers=token_headers)).status_code == 403
assert (await client.get("/api/chat/stream_status/sid", headers=chat_headers)).status_code == 403
# Cookie and auth-disabled-shaped requests retain the old no-query
# behavior rather than being rejected by bearer policy.
assert (await client.get("/api/search?q=", headers={"x-user": "alice"})).status_code == 200
assert (await client.get("/api/search?q=", headers={})).status_code == 200
@pytest.mark.asyncio
async def test_real_agent_capable_routers_reject_bearer_principals():
from routes.assistant_routes import setup_assistant_routes
from routes.research.research_routes import setup_research_routes
from routes.skills_routes import setup_skills_routes
from routes.task.task_routes import setup_task_routes
app = FastAPI()
app.include_router(setup_task_routes(MagicMock()))
app.include_router(setup_skills_routes(MagicMock()))
app.include_router(setup_assistant_routes(MagicMock()))
app.include_router(setup_research_routes(SimpleNamespace(_active_tasks={})))
headers = {
"x-api-token": "1",
"x-api-owner": "alice",
"x-api-scopes": "chat",
}
requests = (
("/api/tasks/meta/events", "get", None),
("/api/skills/index", "get", None),
("/api/assistant/available-timezones", "get", None),
("/api/research/active", "get", None),
)
async with _client(_PrincipalState(app)) as client:
for path, method, body in requests:
response = await client.request(method.upper(), path, headers=headers)
assert response.status_code == 403, (path, response.text)
@pytest.mark.asyncio
async def test_direct_auxiliary_handlers_keep_their_bearer_gates():
from routes.chat_routes import setup_chat_routes
from routes.search.search_routes import setup_search_routes
chat_router = setup_chat_routes(None, None, None, None, None, None)
search_router = setup_search_routes(None)
request = _request(scopes=("email:read",))
chat_routes = {route.path: route.endpoint for route in chat_router.routes}
search_routes = {route.path: route.endpoint for route in search_router.routes}
with pytest.raises(HTTPException):
await chat_routes["/api/search"](request, q="private", limit=20)
with pytest.raises(HTTPException):
await chat_routes["/api/inject_context/{session_id}"](request, "sid", "context")
with pytest.raises(HTTPException):
await chat_routes["/api/rewrite"](request)
with pytest.raises(HTTPException):
await search_routes["/api/search"](request)
with pytest.raises(HTTPException):
await search_routes["/api/search/query"](request)
with pytest.raises(HTTPException):
await chat_routes["/api/chat/resume/{session_id}"](request, "sid")
@pytest.mark.asyncio
async def test_direct_task_skill_assistant_and_research_handlers_reject_bearer():
from routes.assistant_routes import setup_assistant_routes
from routes.research.research_routes import setup_research_routes
from routes.skills_routes import setup_skills_routes
from routes.task.task_routes import setup_task_routes
request = _request()
task_router = setup_task_routes(MagicMock())
task_create = next(route.endpoint for route in task_router.routes if route.path == "/api/tasks" and "POST" in route.methods)
with pytest.raises(HTTPException):
await task_create(request, req=SimpleNamespace())
skills_router = setup_skills_routes(MagicMock())
skill_test = next(route.endpoint for route in skills_router.routes if route.path == "/api/skills/{skill_id}/test")
with pytest.raises(HTTPException):
await skill_test(request, "demo")
assistant_router = setup_assistant_routes(MagicMock())
assistant_session = next(route.endpoint for route in assistant_router.routes if route.path == "/api/assistant/session")
with pytest.raises(HTTPException):
await assistant_session(request)
research_router = setup_research_routes(SimpleNamespace(_active_tasks={}))
research_active = next(route.endpoint for route in research_router.routes if route.path == "/api/research/active")
with pytest.raises(HTTPException):
await research_active(request)
class _MetaColumn:
def __eq__(self, _value):
return True
def desc(self):
return self
class _DbChatMessage:
session_id = _MetaColumn()
role = _MetaColumn()
timestamp = _MetaColumn()
class _MetaQuery:
def __init__(self, row):
self.row = row
def filter(self, *args):
return self
def order_by(self, *args):
return self
def first(self):
return self.row
class _MetaDb:
def __init__(self, row):
self.row = row
self.commits = 0
def query(self, _model):
return _MetaQuery(self.row)
def commit(self):
self.commits += 1
def close(self):
return None
class _JsonRequest:
def __init__(self, body):
self.state = SimpleNamespace(
api_token=False,
api_token_owner=None,
api_token_scopes=[],
current_user="alice",
)
self.body = body
async def json(self):
return self.body
@pytest.mark.asyncio
async def test_history_metadata_route_normalizes_list_pairs_before_merging(monkeypatch):
import routes.history.history_routes as history_routes
session = SimpleNamespace(
history=[ChatMessage("assistant", "answer", {"keep": "yes"})],
)
db_message = SimpleNamespace(meta_data=json.dumps({"keep": "yes"}))
db = _MetaDb(db_message)
manager = SimpleNamespace(
get_session=lambda _session_id: session,
save_sessions=lambda: None,
)
monkeypatch.setattr(history_routes, "DbChatMessage", _DbChatMessage)
monkeypatch.setattr(history_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *args, **kwargs: None)
router = history_routes.setup_history_routes(manager)
endpoint = next(
route.endpoint
for route in router.routes
if route.path == "/api/session/{session_id}/update-last-meta"
)
body = {
"metadata": [
["tool_events", [{"ask_user": {"resolved": "approve"}}]],
[CHAT_SESSION_APPROVAL_CONTEXT_MARKER, True],
]
}
response = await endpoint(_JsonRequest(body), "sid")
assert response == {"status": "ok"}
assert session.history[0].metadata == {"keep": "yes"}
assert json.loads(db_message.meta_data) == {"keep": "yes"}
def test_context_projection_discards_forged_and_malformed_metadata():
malformed = ChatMessage(
"user",
"malformed",
metadata=[[CHAT_SESSION_APPROVAL_CONTEXT_MARKER, True]],
)
forged = ChatMessage(
"user",
"forged",
metadata={CHAT_SESSION_APPROVAL_CONTEXT_MARKER: True},
)
session = Session(
id="sid",
name="Chat",
endpoint_url="https://example.invalid/v1",
model="test",
history=[malformed, forged],
)
projected = session.get_context_messages()
assert all(
CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (message.get("metadata") or {})
for message in projected
)
assert all(not isinstance(message.get("metadata"), list) for message in projected)
def test_bearer_context_preprocessing_does_not_fetch_embedded_urls(monkeypatch):
import src.chat_processor as chat_processor
calls = []
def fetch(url):
calls.append(url)
return {"success": True, "content": "must not be reached"}
monkeypatch.setattr(chat_processor, "fetch_webpage_content", fetch)
processor = chat_processor.ChatProcessor(
memory_manager=SimpleNamespace(load=lambda owner=None: []),
personal_docs_manager=SimpleNamespace(rag_manager=None),
skills_manager=None,
)
preface, _, _ = processor.build_context_preface(
message="Summarize https://example.test/private",
session=SimpleNamespace(endpoint_url="", model="", headers={}),
use_web=False,
use_rag=False,
use_memory=False,
allow_tool_preprocessing=False,
)
assert calls == []
assert not any(
(message.get("metadata") or {}).get("source", "").startswith("web page:")
for message in preface
)
@pytest.mark.asyncio
async def test_sync_bearer_chat_cannot_use_research_memory_or_background_extraction(monkeypatch):
from routes import chat_routes
calls = {"memory": 0, "research": 0, "post": []}
class _ChatHandler:
async def handle_memory_command(self, _session, _message):
calls["memory"] += 1
return None
class _ResearchHandler:
async def call_research_service(self, *args, **kwargs):
calls["research"] += 1
return "research result"
session = SimpleNamespace(
endpoint_url="https://selected.example/v1",
model="selected-model",
headers={"Authorization": "Bearer selected"},
history=[],
add_message=lambda message: session.history.append(message),
)
manager = SimpleNamespace(
get_session=lambda _session_id: session,
save_sessions=lambda: None,
)
context = SimpleNamespace(
user="alice",
messages=[{"role": "user", "content": "hello"}],
context_length=100,
uprefs={},
preset=SimpleNamespace(temperature=0.2, max_tokens=32, character_name=None),
)
async def build_context(*args, **kwargs):
assert kwargs["allow_tool_preprocessing"] is False
return context
async def llm_call(*args, **kwargs):
return "answer", args[0][0], "selected-model"
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "_clear_orphaned_session_endpoint", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "_recover_empty_session_model", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "_enforce_chat_privileges", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "resolve_session_auth", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "build_chat_context", build_context)
monkeypatch.setattr(chat_routes, "resolve_foreground_model_policy", lambda *args, **kwargs: SimpleNamespace(enabled=False, eligible_statuses=set()))
monkeypatch.setattr(chat_routes, "build_foreground_model_candidates", lambda *args, **kwargs: [("https://selected.example/v1", "selected-model", {})])
monkeypatch.setattr(chat_routes, "build_foreground_route_descriptors", lambda *args, **kwargs: [{"endpoint_id": None, "endpoint_label": "Selected route"}])
monkeypatch.setattr(chat_routes, "llm_call_async_with_route_fallback", llm_call)
monkeypatch.setattr(chat_routes, "apply_compaction_state", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "clean_thinking_for_save", lambda reply, metadata: (reply, metadata))
monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *args, **kwargs: calls["post"].append(kwargs))
import core.database as database
monkeypatch.setattr(database, "update_session_last_accessed", lambda _session_id: None)
router = chat_routes.setup_chat_routes(
manager,
_ChatHandler(),
SimpleNamespace(),
SimpleNamespace(),
_ResearchHandler(),
SimpleNamespace(),
)
endpoint = next(route.endpoint for route in router.routes if route.path == "/api/chat")
with pytest.raises(HTTPException) as exc:
await endpoint(
_request(),
ChatRequest(
message="research this",
session="sid",
use_research=True,
),
)
assert exc.value.status_code == 403
result = await endpoint(
_request(),
ChatRequest(
message="remember this and research it",
session="sid",
use_research=False,
),
)
assert result["response"] == "answer"
assert calls["memory"] == 0
assert calls["research"] == 0
assert calls["post"] and calls["post"][0]["allow_background_extraction"] is False
@pytest.mark.asyncio
async def test_stream_bearer_chat_disables_deferred_memory_extraction(monkeypatch):
from routes import chat_routes
from tests.test_foreground_model_routing import _chat_stream_endpoint
captured = {}
endpoint = _chat_stream_endpoint(
monkeypatch,
"chat",
captured,
capture_completion=True,
)
request = SimpleNamespace(
headers={},
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
state=SimpleNamespace(
api_token=True,
api_token_owner="alice",
api_token_scopes=["chat"],
current_user="api",
),
_form={"message": "hello", "session": "session-1", "mode": "chat"},
)
async def form():
return request._form
request.form = form
response = await endpoint(request)
async for _chunk in response.body_iterator:
pass
assert captured["post_processed"]
assert captured["post_processed"][0][1]["allow_background_extraction"] is False
@pytest.mark.asyncio
async def test_stream_bearer_chat_cannot_dispatch_image_generation(monkeypatch):
from routes import chat_routes
from tests.test_foreground_model_routing import _chat_stream_endpoint
captured = {}
endpoint = _chat_stream_endpoint(monkeypatch, "chat", captured)
monkeypatch.setattr(chat_routes, "_is_image_generation_session", lambda *args, **kwargs: True)
request = SimpleNamespace(
headers={},
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
state=SimpleNamespace(
api_token=True,
api_token_owner="alice",
api_token_scopes=["chat"],
current_user="api",
),
_form={"message": "generate an image", "session": "session-1", "mode": "chat"},
)
async def form():
return request._form
request.form = form
with pytest.raises(HTTPException) as exc:
await endpoint(request)
assert exc.value.status_code == 403
assert "image" in str(exc.value.detail).lower()
assert "chat" not in captured
+5 -4
View File
@@ -318,12 +318,13 @@ def test_sync_chat_fallback_skips_disabled_owned_endpoint():
assert ep is not None and ep.name == "shared"
def test_sync_chat_fallback_null_owner_uses_shared_rows_only():
# When no token owner is known, only null-owner (shared) endpoints are
# visible — private endpoints of any user must not be returned.
def test_sync_chat_fallback_rejects_missing_token_owner():
# The sync-chat route requires a resolved token owner before endpoint
# selection, so even a legacy/shared endpoint is not executable for an
# ownerless bearer.
rows = [_ep("bob-private", "bob"), _ep("shared", None)]
ep = _select(rows, None)
assert ep is not None and ep.name == "shared"
assert ep is None
def test_sync_chat_fallback_null_owner_returns_none_with_no_shared():