mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-03 19:52:20 +01:00
fix(security): close bearer side-effect boundaries
This commit is contained in:
+35
-7
@@ -19,6 +19,7 @@ from src.model_context import estimate_tokens, get_context_length
|
||||
from src.auth_helpers import (
|
||||
RequestCapability,
|
||||
effective_user,
|
||||
is_bearer_principal,
|
||||
request_capability as build_request_capability,
|
||||
)
|
||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
@@ -204,6 +205,11 @@ def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
|
||||
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
|
||||
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
|
||||
|
||||
# ``effective_user`` is an attribution/storage identity for bearers, not a
|
||||
# browser privilege principal. In particular, an admin-owned token must
|
||||
# not inherit the owner's ADMIN_PRIVILEGES map through this lookup.
|
||||
if is_bearer_principal(request):
|
||||
return None
|
||||
try:
|
||||
user = effective_user(request)
|
||||
except Exception:
|
||||
@@ -226,6 +232,12 @@ def _enforce_chat_privileges(request, sess) -> None:
|
||||
(single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges,
|
||||
which means unrestricted allowed_models / zero cap -> no-op for them.
|
||||
"""
|
||||
# Bearer authority is defined by the token scope at the route boundary.
|
||||
# Do not turn its owner attribution back into a browser privilege lookup;
|
||||
# that would make an admin-owned token inherit the admin model/cap policy.
|
||||
if is_bearer_principal(request):
|
||||
return
|
||||
|
||||
try:
|
||||
user = effective_user(request)
|
||||
except Exception:
|
||||
@@ -817,11 +829,16 @@ async def build_chat_context(
|
||||
|
||||
# Normalize model ID. Prefer cached endpoint models so group chat does not
|
||||
# re-hit slow local /models endpoints on every participant turn.
|
||||
norm = _normalize_model_id_from_cache(sess) or normalize_model_id(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
owner=getattr(sess, "owner", None),
|
||||
)
|
||||
norm = _normalize_model_id_from_cache(sess)
|
||||
# Model normalization falls back to a live /models or /tags request on a
|
||||
# cache miss. A bearer chat request may use the stored model as-is, but it
|
||||
# must not implicitly refresh an endpoint catalogue while building context.
|
||||
if norm is None and capability.allow_live_probes:
|
||||
norm = normalize_model_id(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
owner=getattr(sess, "owner", None),
|
||||
)
|
||||
if norm:
|
||||
sess.model = norm
|
||||
|
||||
@@ -860,11 +877,22 @@ async def build_chat_context(
|
||||
# session history before we know which route can answer and would make a
|
||||
# later larger-context candidate unable to recover discarded history.
|
||||
if defer_context_shaping:
|
||||
context_length = get_context_length(sess.endpoint_url, sess.model)
|
||||
context_kwargs = {}
|
||||
if not capability.allow_live_probes:
|
||||
context_kwargs["allow_live_probes"] = False
|
||||
context_length = get_context_length(sess.endpoint_url, sess.model, **context_kwargs)
|
||||
was_compacted = False
|
||||
else:
|
||||
compact_kwargs = {"owner": user}
|
||||
if not capability.allow_live_probes:
|
||||
compact_kwargs["allow_live_probes"] = False
|
||||
messages, context_length, was_compacted = await maybe_compact(
|
||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||
sess,
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
messages,
|
||||
sess.headers,
|
||||
**compact_kwargs,
|
||||
)
|
||||
_before_trim_messages = len(messages)
|
||||
_before_trim_tokens = estimate_tokens(messages)
|
||||
|
||||
+45
-22
@@ -167,6 +167,7 @@ def _chat_candidate_request_factory(
|
||||
*,
|
||||
session=None,
|
||||
owner: Optional[str] = None,
|
||||
allow_live_probes: bool = True,
|
||||
):
|
||||
"""Shape one route-neutral Chat prompt for each candidate window."""
|
||||
|
||||
@@ -180,15 +181,20 @@ def _chat_candidate_request_factory(
|
||||
|
||||
async def factory(index, candidate_url, candidate_model, candidate_headers):
|
||||
compaction_state = {}
|
||||
compact_kwargs = {
|
||||
"owner": owner,
|
||||
"persist": False,
|
||||
"compaction_state": compaction_state,
|
||||
}
|
||||
if not allow_live_probes:
|
||||
compact_kwargs["allow_live_probes"] = False
|
||||
candidate_messages, context_length, was_compacted = await maybe_compact(
|
||||
session,
|
||||
candidate_url,
|
||||
candidate_model,
|
||||
list(messages),
|
||||
candidate_headers,
|
||||
owner=owner,
|
||||
persist=False,
|
||||
compaction_state=compaction_state,
|
||||
**compact_kwargs,
|
||||
)
|
||||
if not context_length:
|
||||
context_length = fallback_context_length
|
||||
@@ -878,17 +884,23 @@ def setup_chat_routes(
|
||||
selected_context_length,
|
||||
session=sess,
|
||||
owner=owner,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
requested_model = sess.model
|
||||
llm_kwargs = {
|
||||
"fallback_statuses": foreground_policy.eligible_statuses,
|
||||
"candidate_request_factory": candidate_request_factory,
|
||||
"temperature": ctx.preset.temperature,
|
||||
"max_tokens": ctx.preset.max_tokens,
|
||||
"prompt_type": preset_id,
|
||||
"session_id": session,
|
||||
}
|
||||
if not request_capability.allow_live_probes:
|
||||
llm_kwargs["allow_live_probes"] = False
|
||||
reply, actual_candidate, actual_model = await llm_call_async_with_route_fallback(
|
||||
foreground_candidates,
|
||||
request_messages,
|
||||
fallback_statuses=foreground_policy.eligible_statuses,
|
||||
candidate_request_factory=candidate_request_factory,
|
||||
temperature=ctx.preset.temperature,
|
||||
max_tokens=ctx.preset.max_tokens,
|
||||
prompt_type=preset_id,
|
||||
session_id=session,
|
||||
**llm_kwargs,
|
||||
)
|
||||
actual_index = _candidate_index(foreground_candidates, actual_candidate)
|
||||
apply_compaction_state(
|
||||
@@ -1605,7 +1617,12 @@ def setup_chat_routes(
|
||||
# Enforce per-user privileges
|
||||
_privs = {}
|
||||
_user = ctx.user
|
||||
if _user and hasattr(request.app.state, 'auth_manager') and request.app.state.auth_manager:
|
||||
if (
|
||||
not api_token_request
|
||||
and _user
|
||||
and hasattr(request.app.state, 'auth_manager')
|
||||
and request.app.state.auth_manager
|
||||
):
|
||||
_privs = request.app.state.auth_manager.get_privileges(_user)
|
||||
if _privs:
|
||||
if not _privs.get("can_use_bash", True):
|
||||
@@ -1897,6 +1914,7 @@ def setup_chat_routes(
|
||||
_selected_context_length,
|
||||
session=sess,
|
||||
owner=_user,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
|
||||
# Send model name early so the frontend can show it during streaming
|
||||
@@ -2030,23 +2048,28 @@ def setup_chat_routes(
|
||||
|
||||
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
||||
try:
|
||||
async for chunk in stream_llm_with_fallback(
|
||||
_foreground_candidates,
|
||||
messages,
|
||||
temperature=ctx.preset.temperature,
|
||||
stream_kwargs = {
|
||||
"temperature": ctx.preset.temperature,
|
||||
# Respect the preset; 0/unset = let the server decide (no
|
||||
# cap), matching agent mode. The old hard 4096 fallback
|
||||
# truncated reasoning models mid-<think> — they'd burn the
|
||||
# whole budget thinking and never emit the answer (seen in
|
||||
# Compare on heavy generation prompts).
|
||||
max_tokens=ctx.preset.max_tokens,
|
||||
prompt_type=preset_id,
|
||||
tools=None,
|
||||
session_id=session,
|
||||
fallback_statuses=_foreground_policy.eligible_statuses,
|
||||
fallback_on_empty=_foreground_policy.fallback_on_empty,
|
||||
candidate_request_factory=_chat_request_factory,
|
||||
candidate_route_descriptors=_foreground_route_descriptors,
|
||||
"max_tokens": ctx.preset.max_tokens,
|
||||
"prompt_type": preset_id,
|
||||
"tools": None,
|
||||
"session_id": session,
|
||||
"fallback_statuses": _foreground_policy.eligible_statuses,
|
||||
"fallback_on_empty": _foreground_policy.fallback_on_empty,
|
||||
"candidate_request_factory": _chat_request_factory,
|
||||
"candidate_route_descriptors": _foreground_route_descriptors,
|
||||
}
|
||||
if not request_capability.allow_live_probes:
|
||||
stream_kwargs["allow_live_probes"] = False
|
||||
async for chunk in stream_llm_with_fallback(
|
||||
_foreground_candidates,
|
||||
messages,
|
||||
**stream_kwargs,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
|
||||
+10
-1
@@ -34,7 +34,7 @@ from fastapi import Query, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.auth_helpers import _auth_disabled, get_current_user, is_bearer_principal
|
||||
from src.secret_storage import decrypt as _decrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -420,6 +420,15 @@ def _require_auth(request: Request) -> str:
|
||||
unconfigured mode are only honoured if they're coming from
|
||||
localhost; everyone else gets 401.
|
||||
"""
|
||||
# The legacy email router uses one generic dependency for mailbox reads,
|
||||
# drafts, AI helpers, and SMTP send. It has no per-route token-scope
|
||||
# contract, so a bearer must not be allowed to enter it as the ``api``
|
||||
# pseudo-user. Otherwise owner-scoped lookup can miss the token owner and
|
||||
# fall through to process-wide legacy settings credentials below.
|
||||
# Scope-aware integrations must use their dedicated route boundary.
|
||||
if is_bearer_principal(request):
|
||||
raise HTTPException(403, "API tokens must use a scope-aware email route")
|
||||
|
||||
u = get_current_user(request)
|
||||
if u:
|
||||
return u
|
||||
|
||||
@@ -10,8 +10,11 @@ from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
|
||||
from core.models import ChatMessage
|
||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||
from src.auth_helpers import effective_user, require_chat_scope
|
||||
from src.message_metadata import sanitize_client_message_metadata
|
||||
from src.auth_helpers import effective_user, is_bearer_principal, require_chat_scope
|
||||
from src.message_metadata import (
|
||||
sanitize_client_message_metadata,
|
||||
sanitize_projected_message_metadata,
|
||||
)
|
||||
from src.topic_analyzer import analyze_topics
|
||||
from src.upload_handler import reserve_message_upload_references
|
||||
from routes.session_routes import (
|
||||
@@ -142,11 +145,19 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
f"Referenced upload is no longer available: {missing_id}",
|
||||
)
|
||||
|
||||
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
|
||||
def _display_metadata(value: Any, *, sanitize: bool) -> dict:
|
||||
meta = _metadata_dict(value)
|
||||
if sanitize:
|
||||
return sanitize_projected_message_metadata(meta) or {}
|
||||
return dict(meta)
|
||||
|
||||
def _db_history_entry(
|
||||
m: DbChatMessage,
|
||||
*,
|
||||
sanitize: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
entry = {"role": m.role, "content": _history_display_content(m.content)}
|
||||
meta = _metadata_dict(m.meta_data)
|
||||
if meta:
|
||||
meta = dict(meta)
|
||||
meta = _display_metadata(m.meta_data, sanitize=sanitize)
|
||||
if m.timestamp and "timestamp" not in meta:
|
||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||
if meta:
|
||||
@@ -161,6 +172,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
offset: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
require_chat_scope(request)
|
||||
sanitize_history = is_bearer_principal(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
if limit is not None:
|
||||
page_limit = max(1, min(int(limit), 100))
|
||||
@@ -188,7 +200,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
.all()
|
||||
)
|
||||
history_dict = [
|
||||
entry for entry in (_db_history_entry(m) for m in rows)
|
||||
entry
|
||||
for entry in (
|
||||
_db_history_entry(m, sanitize=sanitize_history)
|
||||
for m in rows
|
||||
)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
]
|
||||
return {
|
||||
@@ -214,7 +230,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
for msg in session.history:
|
||||
if isinstance(msg, ChatMessage):
|
||||
# Skip hidden messages (e.g. compaction summaries for AI context)
|
||||
msg_meta = _metadata_dict(msg.metadata)
|
||||
msg_meta = _display_metadata(
|
||||
msg.metadata,
|
||||
sanitize=sanitize_history,
|
||||
)
|
||||
if msg_meta.get("hidden"):
|
||||
continue
|
||||
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
|
||||
@@ -222,7 +241,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
entry["metadata"] = msg_meta
|
||||
history_dict.append(entry)
|
||||
elif isinstance(msg, dict):
|
||||
msg_meta = _metadata_dict(msg.get("metadata"))
|
||||
msg_meta = _display_metadata(
|
||||
msg.get("metadata"),
|
||||
sanitize=sanitize_history,
|
||||
)
|
||||
if msg_meta.get("hidden"):
|
||||
continue
|
||||
entry = {
|
||||
@@ -248,7 +270,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
)
|
||||
# Response excludes hidden messages, matching the in-memory path.
|
||||
history_dict = [
|
||||
entry for entry in (_db_history_entry(m) for m in db_messages)
|
||||
entry
|
||||
for entry in (
|
||||
_db_history_entry(m, sanitize=sanitize_history)
|
||||
for m in db_messages
|
||||
)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
]
|
||||
except Exception as e:
|
||||
@@ -653,12 +679,15 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
# in-memory messages, corrupting their _db_id and breaking
|
||||
# edit/delete-by-id on the original conversation.
|
||||
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
|
||||
if is_bearer_principal(request):
|
||||
meta = sanitize_projected_message_metadata(meta)
|
||||
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
|
||||
try:
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", getattr(source, 'owner', None))
|
||||
except Exception:
|
||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||
if not is_bearer_principal(request):
|
||||
try:
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", getattr(source, 'owner', None))
|
||||
except Exception:
|
||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
|
||||
+25
-12
@@ -11,7 +11,13 @@ from core.session_manager import SessionManager
|
||||
from core.models import ChatMessage
|
||||
from src.request_models import SessionResponse
|
||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||
from src.auth_helpers import effective_user, _auth_disabled, owner_filter, require_chat_scope
|
||||
from src.auth_helpers import (
|
||||
effective_user,
|
||||
_auth_disabled,
|
||||
is_bearer_principal,
|
||||
owner_filter,
|
||||
require_chat_scope,
|
||||
)
|
||||
from src.message_metadata import sanitize_client_message_metadata
|
||||
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
||||
from src.session_actions import is_session_recently_active
|
||||
@@ -158,7 +164,10 @@ def _reject_raw_endpoint_url_for_non_admin(
|
||||
# Raw URLs make the server dial whatever host the request supplies. For
|
||||
# non-admin users, require a saved endpoint row so normal owner scoping and
|
||||
# endpoint validation have already happened.
|
||||
if user and not _current_user_is_admin(request, user):
|
||||
# A bearer may be attributed to an admin owner for storage and endpoint
|
||||
# visibility, but it is still not an interactive admin principal. Raw
|
||||
# endpoint URLs therefore remain unavailable to every bearer request.
|
||||
if is_bearer_principal(request) or (user and not _current_user_is_admin(request, user)):
|
||||
raise HTTPException(403, "Choose a registered model endpoint")
|
||||
|
||||
|
||||
@@ -450,14 +459,17 @@ def setup_session_routes(
|
||||
from src.endpoint_resolver import build_headers
|
||||
session.headers = build_headers(resolved_key, resolved_base)
|
||||
_persist_session_headers(sid, session.headers)
|
||||
# Fire webhook (sync-safe)
|
||||
if webhook_manager:
|
||||
webhook_manager.fire_and_forget("session.created", {
|
||||
"session_id": sid, "name": session.name, "model": model_to_use,
|
||||
})
|
||||
# Fire event for automation tasks
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", user)
|
||||
# A bearer can create owner-attributed chat data, but must not cause
|
||||
# owner lifecycle automation or webhook delivery as a side effect.
|
||||
if not is_bearer_principal(request):
|
||||
# Fire webhook (sync-safe)
|
||||
if webhook_manager:
|
||||
webhook_manager.fire_and_forget("session.created", {
|
||||
"session_id": sid, "name": session.name, "model": model_to_use,
|
||||
})
|
||||
# Fire event for automation tasks
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", user)
|
||||
return SessionResponse(
|
||||
id=sid,
|
||||
name=session.name,
|
||||
@@ -943,8 +955,9 @@ def setup_session_routes(
|
||||
)
|
||||
session.headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
|
||||
session_manager.save_sessions()
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", user)
|
||||
if not is_bearer_principal(request):
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", user)
|
||||
return {"id": sid, "name": "", "model": model}
|
||||
|
||||
@router.post("/session/{session_id}/important")
|
||||
|
||||
@@ -21,7 +21,7 @@ from core.database import (
|
||||
Note,
|
||||
Session as DbSession,
|
||||
)
|
||||
from src.auth_helpers import effective_user, require_chat_scope
|
||||
from src.auth_helpers import effective_user, require_chat_scope, require_non_bearer_request
|
||||
from src.attachment_refs import attachment_refs_from_metadata
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
from src.upload_handler import (
|
||||
@@ -462,6 +462,7 @@ def setup_upload_routes(upload_handler):
|
||||
Cached under UPLOAD_DIR/.vision/{file_id}.txt — first call computes,
|
||||
subsequent loads are instant. Pass force=1 to recompute."""
|
||||
require_chat_scope(request)
|
||||
require_non_bearer_request(request)
|
||||
if not upload_handler.validate_upload_id(file_id):
|
||||
raise HTTPException(400, "Invalid file ID")
|
||||
info = _load_upload_info(file_id)
|
||||
@@ -507,6 +508,7 @@ def setup_upload_routes(upload_handler):
|
||||
"""Persist a user-edited vision/OCR text for an attachment. Stored in
|
||||
the same cache file so the chat send picks it up as the override."""
|
||||
require_chat_scope(request)
|
||||
require_non_bearer_request(request)
|
||||
if not upload_handler.validate_upload_id(file_id):
|
||||
raise HTTPException(400, "Invalid file ID")
|
||||
info = _load_upload_info(file_id)
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Request, Form
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.database import SessionLocal, Webhook, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter, require_chat_scope
|
||||
from src.auth_helpers import is_bearer_principal, owner_filter, require_chat_scope
|
||||
from src.url_security import validate_public_http_url
|
||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||
|
||||
@@ -385,10 +385,14 @@ def setup_webhook_routes(
|
||||
sess.add_message(ChatMessage("assistant", reply))
|
||||
session_manager.save_sessions()
|
||||
|
||||
webhook_manager.fire_and_forget("chat.completed", {
|
||||
"session_id": session_id, "model": sess.model,
|
||||
"user_message": message[:2000], "response": reply[:2000],
|
||||
})
|
||||
# /api/v1/chat remains a synchronous bearer integration: the response
|
||||
# is returned normally, but the token must not fan that content out to
|
||||
# an owner-configured asynchronous callback after authorization ends.
|
||||
if not is_bearer_principal(request):
|
||||
webhook_manager.fire_and_forget("chat.completed", {
|
||||
"session_id": session_id, "model": sess.model,
|
||||
"user_message": message[:2000], "response": reply[:2000],
|
||||
})
|
||||
|
||||
return {"response": reply, "session_id": session_id, "model": sess.model}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user