mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-28 07:35:27 -04:00
fix(security): gate codex cookbook routes behind admin check for cookie sessions (#4554)
The Codex cookbook bridge authorized cookie sessions with require_user() only, allowing non-admin accounts to read cookbook task state, server topology, task logs, tmux sessions, and model presets. The stop/adopt routes also execute local or SSH-backed tmux commands. Add _require_cookbook_scope() that enforces require_admin() for cookie-session callers while preserving the existing API-token scope checks. Apply it to all nine /api/codex/cookbook/* routes. Fixes #4542 Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""Codex cookbook routes require admin for cookie-session callers.
|
||||
|
||||
Regression test for issue #4542: non-admin users could reach cookbook
|
||||
routes (tasks, servers, output, stop, adopt, presets, etc.) through
|
||||
normal cookie sessions because _scope_owner only checked login status,
|
||||
not admin privileges.
|
||||
|
||||
After the fix, cookie-session callers must be admin; API-token callers
|
||||
are still governed by scope checks only.
|
||||
"""
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routes.codex_routes import _require_cookbook_scope
|
||||
|
||||
|
||||
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
|
||||
COOKBOOK_LAUNCH_SCOPES = {"cookbook:launch"}
|
||||
|
||||
|
||||
def _cookie_request(*, current_user="bob", is_admin=False):
|
||||
"""Simulate a cookie-session request (no api_token)."""
|
||||
auth_mgr = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: is_admin and user == "bob",
|
||||
)
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
current_user=current_user,
|
||||
api_token=False,
|
||||
),
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_mgr)),
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
def _api_token_request(*, scopes=None, owner="alice"):
|
||||
"""Simulate an API-token request."""
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
current_user="api",
|
||||
api_token=True,
|
||||
api_token_scopes=scopes or [],
|
||||
api_token_owner=owner,
|
||||
),
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
class TestCookieSessionAdminGate:
|
||||
"""Non-admin cookie sessions must be rejected; admin sessions allowed."""
|
||||
|
||||
def test_non_admin_rejected_read(self, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
req = _cookie_request(is_admin=False)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_require_cookbook_scope(req, COOKBOOK_READ_SCOPES)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
def test_non_admin_rejected_launch(self, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
req = _cookie_request(is_admin=False)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_require_cookbook_scope(req, COOKBOOK_LAUNCH_SCOPES)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
def test_admin_allowed_read(self, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
req = _cookie_request(is_admin=True)
|
||||
owner = _require_cookbook_scope(req, COOKBOOK_READ_SCOPES)
|
||||
assert owner == "bob"
|
||||
|
||||
def test_admin_allowed_launch(self, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
req = _cookie_request(is_admin=True)
|
||||
owner = _require_cookbook_scope(req, COOKBOOK_LAUNCH_SCOPES)
|
||||
assert owner == "bob"
|
||||
|
||||
|
||||
class TestApiTokenScopeGate:
|
||||
"""API-token callers are governed by scope, not admin status."""
|
||||
|
||||
def test_token_with_scope_allowed(self, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
req = _api_token_request(scopes=["cookbook:read"])
|
||||
owner = _require_cookbook_scope(req, COOKBOOK_READ_SCOPES)
|
||||
assert owner == "alice"
|
||||
|
||||
def test_token_missing_scope_rejected(self, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
req = _api_token_request(scopes=["unrelated:scope"])
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_require_cookbook_scope(req, COOKBOOK_READ_SCOPES)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
class TestSourceCodeGate:
|
||||
"""Static checks: all cookbook routes use _require_cookbook_scope."""
|
||||
|
||||
def test_no_raw_scope_owner_in_cookbook_routes(self):
|
||||
from pathlib import Path
|
||||
source = Path("routes/codex_routes.py").read_text(encoding="utf-8")
|
||||
# _scope_owner should NOT appear inside cookbook route handlers.
|
||||
# Find lines between cookbook route defs that still call _scope_owner.
|
||||
in_cookbook = False
|
||||
violations = []
|
||||
for i, line in enumerate(source.splitlines(), 1):
|
||||
if "@router." in line and "/cookbook/" in line:
|
||||
in_cookbook = True
|
||||
elif "@router." in line and "/cookbook/" not in line:
|
||||
in_cookbook = False
|
||||
if in_cookbook and "_scope_owner(request" in line:
|
||||
violations.append((i, line.strip()))
|
||||
assert violations == [], (
|
||||
f"Cookbook routes still use _scope_owner instead of _require_cookbook_scope: {violations}"
|
||||
)
|
||||
Reference in New Issue
Block a user