mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
* fix(security): stop API tokens reaching privileged agent tools A bearer API token resolves to the human who minted it, and minting is admin-only, so every owner-keyed privilege check in the agent path answers "admin". A token issued for a narrow integration therefore reached bash and python with the authority of the account that created it. Three independent routes to that sink, each closed here. The token could answer its own tool-approval prompt. An approval records that a person authorized one dangerous action, and a token cannot make that statement, so /api/chat_stream now refuses an approval resume from a bearer caller. The chat-session grant was reconstructable from caller-supplied message metadata. Two routes persist a metadata blob on the caller's behalf, so the shape of a resolved approval card could be written straight into a transcript and was then read back as authority. The server now signs the grant when it resolves an approval and verifies that signature when reading it back, binding it to the chat and the approval it was issued for. Both routes also drop server-owned keys from an inbound blob. A run driven by a token inherited its owner's tool set. Such a run is now capped at the non-admin policy regardless of who minted the credential, which holds even where no approval is raised at all. The human path is unchanged: a browser session still receives the prompt, still approves, and a granted chat-session scope still carries to later turns in that chat. Scope enforcement across the wider route surface is a separate gap and is not addressed here. * fix scoped chat delegation boundaries * fix(auth): reject malformed chat approval signatures --------- Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
107 lines
3.6 KiB
Python
107 lines
3.6 KiB
Python
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
# Import the route helper during collection so sibling session tests that use
|
|
# partial import stubs do not become the first loader of core.session_manager.
|
|
from routes.session_routes import (
|
|
_reject_delegated_session_options,
|
|
_reject_raw_endpoint_url_for_non_admin,
|
|
)
|
|
|
|
|
|
def _request(user, *, admin=False, api_token=False, scopes=None):
|
|
auth_manager = SimpleNamespace(is_admin=lambda username: bool(admin))
|
|
return SimpleNamespace(
|
|
state=SimpleNamespace(
|
|
current_user="api" if api_token else user,
|
|
api_token=api_token,
|
|
api_token_owner=user if api_token else None,
|
|
api_token_scopes=scopes or [],
|
|
),
|
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
|
|
)
|
|
|
|
|
|
def test_non_admin_session_create_rejects_raw_endpoint_url_without_endpoint_id():
|
|
with pytest.raises(HTTPException) as exc:
|
|
_reject_raw_endpoint_url_for_non_admin(
|
|
_request("alice", admin=False),
|
|
"alice",
|
|
"",
|
|
"http://169.254.169.254/latest/meta-data",
|
|
)
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_admin_and_registered_endpoint_can_use_endpoint_url():
|
|
_reject_raw_endpoint_url_for_non_admin(
|
|
_request("alice", admin=False),
|
|
"alice",
|
|
"endpoint-id",
|
|
"http://127.0.0.1:8000/v1/chat/completions",
|
|
)
|
|
_reject_raw_endpoint_url_for_non_admin(
|
|
_request("admin", admin=True),
|
|
"admin",
|
|
"",
|
|
"http://127.0.0.1:8000/v1/chat/completions",
|
|
)
|
|
|
|
|
|
def test_bearer_token_does_not_inherit_owner_admin_raw_endpoint_authority():
|
|
request = _request("admin", admin=True, api_token=True, scopes=["chat"])
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
_reject_raw_endpoint_url_for_non_admin(
|
|
request,
|
|
"admin",
|
|
"",
|
|
"http://127.0.0.1:8000/v1/chat/completions",
|
|
)
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_chat_scoped_bearer_can_still_choose_an_owner_registered_endpoint():
|
|
_reject_raw_endpoint_url_for_non_admin(
|
|
_request("admin", admin=True, api_token=True, scopes=["chat"]),
|
|
"admin",
|
|
"owner-endpoint-id",
|
|
"http://127.0.0.1:8000/v1/chat/completions",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("skip_validation", "api_key"),
|
|
[(True, ""), (False, "caller-secret")],
|
|
)
|
|
def test_bearer_token_cannot_use_interactive_session_options(
|
|
skip_validation,
|
|
api_key,
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
_reject_delegated_session_options(
|
|
_request("admin", admin=True, api_token=True, scopes=["chat"]),
|
|
skip_validation=skip_validation,
|
|
api_key=api_key,
|
|
)
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_chat_endpoint_recovery_paths_are_owner_scoped():
|
|
root = Path(__file__).resolve().parents[1]
|
|
chat_routes = (root / "routes" / "chat_routes.py").read_text(encoding="utf-8")
|
|
chat_helpers = (root / "routes" / "chat_helpers.py").read_text(encoding="utf-8")
|
|
|
|
assert "def _clear_orphaned_session_endpoint(sess, owner:" in chat_routes
|
|
assert "def _recover_empty_session_model(sess, session_id: str, owner:" in chat_routes
|
|
assert "q = owner_filter(q, ModelEndpoint, owner)" in chat_routes
|
|
assert "resolve_session_auth(sess, session, owner=effective_user(request))" in chat_routes
|
|
assert "def resolve_session_auth(sess, session_id: str, owner:" in chat_helpers
|
|
assert "update_q = update_q.filter(DBSession.owner == owner)" in chat_helpers
|