mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-13 02:12:21 +02:00
security: fail closed on bearer endpoint credentials
This commit is contained in:
@@ -683,7 +683,7 @@ def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[s
|
|||||||
Direct API-key sessions intentionally have no ``ModelEndpoint`` row and
|
Direct API-key sessions intentionally have no ``ModelEndpoint`` row and
|
||||||
retain their documented compatibility behavior. Registered endpoint
|
retain their documented compatibility behavior. Registered endpoint
|
||||||
sessions, including provider-auth-backed rows, must use the visible
|
sessions, including provider-auth-backed rows, must use the visible
|
||||||
server-owned inventory and never trigger a provider lookup here.
|
server-owned inventory and never trigger a live provider lookup here.
|
||||||
"""
|
"""
|
||||||
# Lightweight in-memory test doubles from older route tests do not carry
|
# Lightweight in-memory test doubles from older route tests do not carry
|
||||||
# durable provenance fields. They cannot represent a persisted bearer
|
# durable provenance fields. They cannot represent a persisted bearer
|
||||||
@@ -737,9 +737,9 @@ def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[s
|
|||||||
validated = _validate_bearer_model_selection(ep, requested)
|
validated = _validate_bearer_model_selection(ep, requested)
|
||||||
|
|
||||||
# A session may outlive an endpoint-key rotation. For bearer calls,
|
# A session may outlive an endpoint-key rotation. For bearer calls,
|
||||||
# use the current static key for this exact endpoint and never trust a
|
# use the current exact-endpoint credentials and never trust a stale
|
||||||
# stale persisted Authorization header. Provider-auth rows remain
|
# persisted Authorization header. Provider-auth credentials are
|
||||||
# request-local and are intentionally empty in cache-only mode.
|
# owner-scoped, request-local, and cache-only in this boundary.
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
|
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
|
||||||
|
|
||||||
@@ -752,6 +752,9 @@ def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[s
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Could not refresh bearer session endpoint auth: %s", exc)
|
logger.warning("Could not refresh bearer session endpoint auth: %s", exc)
|
||||||
sess.headers = {}
|
sess.headers = {}
|
||||||
|
if getattr(ep, "provider_auth_id", None):
|
||||||
|
raise HTTPException(401, "Registered provider credentials are unavailable") from exc
|
||||||
|
raise HTTPException(400, "Registered endpoint credentials are unavailable") from exc
|
||||||
|
|
||||||
sess.model = validated
|
sess.model = validated
|
||||||
return validated
|
return validated
|
||||||
|
|||||||
@@ -997,6 +997,14 @@ def setup_session_routes(
|
|||||||
rag: str = Form(None)
|
rag: str = Form(None)
|
||||||
):
|
):
|
||||||
require_chat_scope(request)
|
require_chat_scope(request)
|
||||||
|
# This legacy alias uses the server-owned OPENAI_API_KEY rather than a
|
||||||
|
# caller-selected, owner-visible ModelEndpoint. A bearer must not turn
|
||||||
|
# that credential into an owner-attributed session whose provenance
|
||||||
|
# cannot be represented as either a registered endpoint or a direct
|
||||||
|
# caller-supplied key. Registered bearer chat remains available through
|
||||||
|
# POST /api/session with endpoint_id.
|
||||||
|
if is_bearer_principal(request):
|
||||||
|
raise HTTPException(403, "Bearer callers must choose a registered model endpoint")
|
||||||
if not OPENAI_API_KEY:
|
if not OPENAI_API_KEY:
|
||||||
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
||||||
sid = str(uuid.uuid4())
|
sid = str(uuid.uuid4())
|
||||||
|
|||||||
@@ -258,10 +258,6 @@ def resolve_runtime_credentials(
|
|||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
allow_live_probes: bool = True,
|
allow_live_probes: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
if not allow_live_probes:
|
|
||||||
raise ChatGPTSubscriptionReauthRequired(
|
|
||||||
"ChatGPT Subscription credentials are unavailable when live probes are disabled."
|
|
||||||
)
|
|
||||||
ProviderAuthSession, SessionLocal, utcnow_naive = _database_handles()
|
ProviderAuthSession, SessionLocal, utcnow_naive = _database_handles()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -276,6 +272,21 @@ def resolve_runtime_credentials(
|
|||||||
raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription credentials were not found for this user.")
|
raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription credentials were not found for this user.")
|
||||||
|
|
||||||
access_token = row.access_token or ""
|
access_token = row.access_token or ""
|
||||||
|
if not allow_live_probes:
|
||||||
|
# Bearer chat may use an owner-scoped access token already held in
|
||||||
|
# the encrypted provider-auth row, but it must not refresh OAuth or
|
||||||
|
# probe the provider. Reject a missing/near-expiry cache entry so a
|
||||||
|
# request cannot reach the provider without valid Authorization.
|
||||||
|
if not access_token or access_token_is_expiring(access_token):
|
||||||
|
raise ChatGPTSubscriptionReauthRequired(
|
||||||
|
"ChatGPT Subscription credentials are unavailable without a live refresh."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"provider": CHATGPT_SUBSCRIPTION_PROVIDER,
|
||||||
|
"base_url": (row.base_url or DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL).rstrip("/"),
|
||||||
|
"api_key": access_token,
|
||||||
|
"auth_mode": row.auth_mode or "chatgpt",
|
||||||
|
}
|
||||||
if force_refresh or access_token_is_expiring(access_token):
|
if force_refresh or access_token_is_expiring(access_token):
|
||||||
with _refresh_lock_for(auth_id):
|
with _refresh_lock_for(auth_id):
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
|||||||
@@ -158,10 +158,13 @@ def resolve_endpoint_runtime(
|
|||||||
base = normalize_base(getattr(ep, "base_url", "") or "")
|
base = normalize_base(getattr(ep, "base_url", "") or "")
|
||||||
api_key = getattr(ep, "api_key", None)
|
api_key = getattr(ep, "api_key", None)
|
||||||
auth_id = getattr(ep, "provider_auth_id", None)
|
auth_id = getattr(ep, "provider_auth_id", None)
|
||||||
if auth_id and allow_live_probes:
|
if auth_id:
|
||||||
from src.chatgpt_subscription import resolve_runtime_credentials
|
from src.chatgpt_subscription import resolve_runtime_credentials
|
||||||
|
|
||||||
creds = resolve_runtime_credentials(auth_id, owner=owner)
|
credential_kwargs = {}
|
||||||
|
if not allow_live_probes:
|
||||||
|
credential_kwargs["allow_live_probes"] = False
|
||||||
|
creds = resolve_runtime_credentials(auth_id, owner=owner, **credential_kwargs)
|
||||||
base = normalize_base(creds.get("base_url") or base)
|
base = normalize_base(creds.get("base_url") or base)
|
||||||
api_key = creds.get("api_key")
|
api_key = creds.get("api_key")
|
||||||
return base, api_key
|
return base, api_key
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ def _load_webhook_routes_for_test(monkeypatch):
|
|||||||
core_pkg.__path__ = []
|
core_pkg.__path__ = []
|
||||||
core_db = types.ModuleType("core.database")
|
core_db = types.ModuleType("core.database")
|
||||||
core_db.SessionLocal = object
|
core_db.SessionLocal = object
|
||||||
|
# The production sync-chat path reuses the central bearer session
|
||||||
|
# validator, which imports the durable Session model at invocation time.
|
||||||
|
core_db.Session = object
|
||||||
core_db.Webhook = object
|
core_db.Webhook = object
|
||||||
core_db.ModelEndpoint = object
|
core_db.ModelEndpoint = object
|
||||||
core_middleware = types.ModuleType("core.middleware")
|
core_middleware = types.ModuleType("core.middleware")
|
||||||
@@ -242,10 +245,12 @@ def _install_sync_chat_stubs(monkeypatch):
|
|||||||
endpoint_resolver.normalize_base = lambda url: (url or "").strip().rstrip("/")
|
endpoint_resolver.normalize_base = lambda url: (url or "").strip().rstrip("/")
|
||||||
endpoint_resolver.build_chat_url = lambda base_url: f"{base_url}/chat/completions"
|
endpoint_resolver.build_chat_url = lambda base_url: f"{base_url}/chat/completions"
|
||||||
endpoint_resolver.build_models_url = lambda base_url: f"{base_url}/models"
|
endpoint_resolver.build_models_url = lambda base_url: f"{base_url}/models"
|
||||||
|
endpoint_resolver.resolve_endpoint = lambda *args, **kwargs: None
|
||||||
endpoint_resolver.build_headers = lambda api_key, base_url: {"Authorization": f"Bearer {api_key}"}
|
endpoint_resolver.build_headers = lambda api_key, base_url: {"Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
llm_core = types.ModuleType("src.llm_core")
|
llm_core = types.ModuleType("src.llm_core")
|
||||||
llm_core.llm_call_async = _llm_call_async
|
llm_core.llm_call_async = _llm_call_async
|
||||||
|
llm_core.normalize_model_id = lambda *args, **kwargs: None
|
||||||
core_models.ChatMessage = _ChatMessage
|
core_models.ChatMessage = _ChatMessage
|
||||||
|
|
||||||
monkeypatch.setitem(sys.modules, "python_multipart", python_multipart)
|
monkeypatch.setitem(sys.modules, "python_multipart", python_multipart)
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ def test_bearer_session_model_is_checked_against_endpoint_inventory(monkeypatch)
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_chat_uses_cached_model_and_skips_provider_runtime_resolution(monkeypatch):
|
async def test_sync_chat_uses_cached_provider_auth_without_live_refresh(monkeypatch):
|
||||||
from routes import webhook_routes
|
from routes import webhook_routes
|
||||||
from src import chatgpt_subscription, llm_core
|
from src import chatgpt_subscription, llm_core
|
||||||
|
|
||||||
@@ -367,9 +367,10 @@ async def test_sync_chat_uses_cached_model_and_skips_provider_runtime_resolution
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
chatgpt_subscription,
|
chatgpt_subscription,
|
||||||
"resolve_runtime_credentials",
|
"resolve_runtime_credentials",
|
||||||
lambda *args, **kwargs: runtime_calls.append((args, kwargs)) or pytest.fail(
|
lambda *args, **kwargs: runtime_calls.append((args, kwargs)) or {
|
||||||
"bearer sync resolved provider credentials"
|
"base_url": endpoint.base_url,
|
||||||
),
|
"api_key": "cached-access-token",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
llm_calls = []
|
llm_calls = []
|
||||||
|
|
||||||
@@ -410,24 +411,58 @@ async def test_sync_chat_uses_cached_model_and_skips_provider_runtime_resolution
|
|||||||
|
|
||||||
result = await route(request=_Request(), body=body)
|
result = await route(request=_Request(), body=body)
|
||||||
assert result["model"] == "allowed-model"
|
assert result["model"] == "allowed-model"
|
||||||
assert runtime_calls == []
|
assert runtime_calls == [
|
||||||
|
(("provider-auth",), {"owner": "alice", "allow_live_probes": False})
|
||||||
|
]
|
||||||
|
assert llm_calls[0]["headers"]["Authorization"] == "Bearer cached-access-token"
|
||||||
assert llm_calls[0]["allow_live_probes"] is False
|
assert llm_calls[0]["allow_live_probes"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_provider_runtime_guard_is_no_live_without_opening_credentials_db(monkeypatch):
|
def test_provider_runtime_guard_uses_cached_credentials_without_refresh(monkeypatch):
|
||||||
from src import chatgpt_subscription
|
from src import chatgpt_subscription
|
||||||
|
|
||||||
|
row = SimpleNamespace(
|
||||||
|
access_token="cached-access-token",
|
||||||
|
base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
auth_mode="chatgpt",
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Query:
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return row
|
||||||
|
|
||||||
|
class _Db:
|
||||||
|
def query(self, _model):
|
||||||
|
return _Query()
|
||||||
|
|
||||||
|
def refresh(self, _row):
|
||||||
|
raise AssertionError("cache-only provider resolution refreshed credentials")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
chatgpt_subscription,
|
chatgpt_subscription,
|
||||||
"_database_handles",
|
"_database_handles",
|
||||||
lambda: pytest.fail("no-live provider guard opened the credentials database"),
|
lambda: (cdb.ProviderAuthSession, lambda: _Db(), lambda: None),
|
||||||
)
|
)
|
||||||
with pytest.raises(chatgpt_subscription.ChatGPTSubscriptionReauthRequired):
|
monkeypatch.setattr(
|
||||||
chatgpt_subscription.resolve_runtime_credentials(
|
chatgpt_subscription, "access_token_is_expiring", lambda token: False
|
||||||
"provider-auth",
|
)
|
||||||
owner="alice",
|
monkeypatch.setattr(
|
||||||
allow_live_probes=False,
|
chatgpt_subscription,
|
||||||
)
|
"refresh_oauth_tokens",
|
||||||
|
lambda *args, **kwargs: pytest.fail("cache-only provider resolution refreshed credentials"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = chatgpt_subscription.resolve_runtime_credentials(
|
||||||
|
"provider-auth", owner="alice", allow_live_probes=False
|
||||||
|
)
|
||||||
|
assert result["api_key"] == "cached-access-token"
|
||||||
|
assert result["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||||
|
|
||||||
|
|
||||||
def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolution(monkeypatch):
|
def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolution(monkeypatch):
|
||||||
@@ -446,10 +481,14 @@ def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolutio
|
|||||||
hidden_models=None,
|
hidden_models=None,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(endpoint_resolver, "SessionLocal", lambda: _EndpointDb(endpoint))
|
monkeypatch.setattr(endpoint_resolver, "SessionLocal", lambda: _EndpointDb(endpoint))
|
||||||
|
runtime_calls = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
chatgpt_subscription,
|
chatgpt_subscription,
|
||||||
"resolve_runtime_credentials",
|
"resolve_runtime_credentials",
|
||||||
lambda *args, **kwargs: pytest.fail("foreground descriptor resolved provider credentials"),
|
lambda *args, **kwargs: runtime_calls.append((args, kwargs)) or {
|
||||||
|
"base_url": endpoint.base_url,
|
||||||
|
"api_key": "cached-access-token",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
descriptors = foreground_model_routing.build_foreground_route_descriptors(
|
descriptors = foreground_model_routing.build_foreground_route_descriptors(
|
||||||
@@ -461,6 +500,9 @@ def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolutio
|
|||||||
allow_live_probes=False,
|
allow_live_probes=False,
|
||||||
)
|
)
|
||||||
assert descriptors[0]["endpoint_label"] in {"Subscription", "Selected route"}
|
assert descriptors[0]["endpoint_label"] in {"Subscription", "Selected route"}
|
||||||
|
assert runtime_calls == [
|
||||||
|
(("provider-auth",), {"owner": "alice", "allow_live_probes": False})
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Cycle-8 regressions for bearer provider-auth session repair."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
import core.database as cdb
|
||||||
|
|
||||||
|
|
||||||
|
_CODEX_BASE = "https://chatgpt.com/backend-api/codex"
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_db(monkeypatch):
|
||||||
|
from routes import chat_helpers
|
||||||
|
from src import chatgpt_subscription
|
||||||
|
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
cdb.Base.metadata.create_all(bind=engine)
|
||||||
|
test_session_local = sessionmaker(bind=engine, autoflush=False)
|
||||||
|
monkeypatch.setattr(chat_helpers, "SessionLocal", test_session_local)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chatgpt_subscription,
|
||||||
|
"_database_handles",
|
||||||
|
lambda: (cdb.ProviderAuthSession, test_session_local, cdb.utcnow_naive),
|
||||||
|
)
|
||||||
|
|
||||||
|
db = test_session_local()
|
||||||
|
db.add(cdb.ProviderAuthSession(
|
||||||
|
id="auth-1",
|
||||||
|
provider="chatgpt-subscription",
|
||||||
|
owner="alice",
|
||||||
|
base_url=_CODEX_BASE,
|
||||||
|
access_token="cached-access-token",
|
||||||
|
refresh_token="refresh-token",
|
||||||
|
auth_mode="chatgpt",
|
||||||
|
))
|
||||||
|
db.add(cdb.ModelEndpoint(
|
||||||
|
id="endpoint-1",
|
||||||
|
name="ChatGPT Subscription",
|
||||||
|
base_url=_CODEX_BASE,
|
||||||
|
api_key=None,
|
||||||
|
provider_auth_id="auth-1",
|
||||||
|
owner="alice",
|
||||||
|
is_enabled=True,
|
||||||
|
endpoint_kind="api",
|
||||||
|
cached_models=json.dumps(["gpt-5.5"]),
|
||||||
|
pinned_models=json.dumps(["gpt-5.5"]),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return test_session_local
|
||||||
|
|
||||||
|
|
||||||
|
def _registered_session():
|
||||||
|
return SimpleNamespace(
|
||||||
|
endpoint_url=f"{_CODEX_BASE}/responses",
|
||||||
|
model="gpt-5.5",
|
||||||
|
model_endpoint_id="endpoint-1",
|
||||||
|
endpoint_provenance="registered",
|
||||||
|
headers={"Authorization": "Bearer stale-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_validator_uses_owner_cached_provider_auth_without_refresh(monkeypatch):
|
||||||
|
from routes.chat_helpers import _validate_bearer_session_model
|
||||||
|
from src import chatgpt_subscription
|
||||||
|
|
||||||
|
_provider_db(monkeypatch)
|
||||||
|
monkeypatch.setattr(chatgpt_subscription, "access_token_is_expiring", lambda token: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chatgpt_subscription,
|
||||||
|
"refresh_oauth_tokens",
|
||||||
|
lambda *args, **kwargs: pytest.fail("bearer validation refreshed provider credentials"),
|
||||||
|
)
|
||||||
|
|
||||||
|
session = _registered_session()
|
||||||
|
assert _validate_bearer_session_model(session, owner="alice") == "gpt-5.5"
|
||||||
|
assert session.headers["Authorization"] == "Bearer cached-access-token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_validator_rejects_provider_auth_when_cache_is_unusable(monkeypatch):
|
||||||
|
from routes.chat_helpers import _validate_bearer_session_model
|
||||||
|
from src import chatgpt_subscription
|
||||||
|
|
||||||
|
_provider_db(monkeypatch)
|
||||||
|
monkeypatch.setattr(chatgpt_subscription, "access_token_is_expiring", lambda token: True)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_validate_bearer_session_model(_registered_session(), owner="alice")
|
||||||
|
assert exc.value.status_code == 401
|
||||||
@@ -162,8 +162,10 @@ def test_bearer_session_lifecycle_routes_do_not_emit_webhook_or_event(monkeypatc
|
|||||||
assert result.model == "stored-model"
|
assert result.model == "stored-model"
|
||||||
|
|
||||||
create_openai = _latest_endpoint(router, "/api/session/openai", "POST")
|
create_openai = _latest_endpoint(router, "/api/session/openai", "POST")
|
||||||
openai_result = create_openai(request, name="OpenAI", model="gpt-4o", rag="false")
|
with pytest.raises(HTTPException) as exc:
|
||||||
assert openai_result["model"] == "gpt-4o"
|
create_openai(request, name="OpenAI", model="gpt-4o", rag="false")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert len(manager.sessions) == 1
|
||||||
webhook_manager.fire_and_forget.assert_not_called()
|
webhook_manager.fire_and_forget.assert_not_called()
|
||||||
assert events == []
|
assert events == []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user