fix(security): close bearer auxiliary boundaries

This commit is contained in:
RaresKeY
2026-08-29 12:27:14 +00:00
parent 31c7249ef3
commit e1da1264dc
9 changed files with 804 additions and 88 deletions
+5
View File
@@ -2821,6 +2821,7 @@ def setup_chat_routes(
It just asks the LLM to rewrite the given text.
"""
require_chat_scope(request)
capability = build_request_capability(request)
try:
body = await request.json()
except Exception:
@@ -2855,6 +2856,9 @@ def setup_chat_routes(
async def stream_rewrite() -> AsyncGenerator[str, None]:
full_response = ""
try:
stream_kwargs = {}
if not capability.allow_live_probes:
stream_kwargs["allow_live_probes"] = False
async for chunk in stream_llm(
sess.endpoint_url,
sess.model,
@@ -2867,6 +2871,7 @@ def setup_chat_routes(
# on "Rewriting...". Same fix as the chat max_tokens cap.
max_tokens=0,
tools=None,
**stream_kwargs,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
+30 -4
View File
@@ -10,10 +10,16 @@ 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, is_bearer_principal, require_chat_scope
from src.auth_helpers import (
effective_user,
is_bearer_principal,
request_capability,
require_chat_scope,
)
from src.message_metadata import (
sanitize_client_message_metadata,
sanitize_projected_message_metadata,
normalize_client_message_role,
)
from src.topic_analyzer import analyze_topics
from src.upload_handler import reserve_message_upload_references
@@ -311,7 +317,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
_verify_session_owner(request, session_id)
try:
body = await request.json()
role = body.get("role", "assistant")
role = normalize_client_message_role(body.get("role", "assistant"))
content = body.get("content", "")
if not content:
raise HTTPException(400, "content is required")
@@ -720,6 +726,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
when the whole chat is approaching compaction.
"""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
@@ -731,7 +738,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
messages = session.get_context_messages()
used = int(estimate_tokens(messages))
ctx_len = int(get_context_length(session.endpoint_url, session.model) or 0)
context_kwargs = {}
if not capability.allow_live_probes:
context_kwargs["allow_live_probes"] = False
ctx_len = int(get_context_length(
session.endpoint_url,
session.model,
**context_kwargs,
) or 0)
pct = round((used / ctx_len) * 100, 1) if ctx_len else 0.0
pct = max(0.0, min(100.0, pct))
visible_messages = sum(
@@ -765,6 +779,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
async def compact_session(request: Request, session_id: str):
"""Manually trigger context compaction for a session."""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id)
from src.auth_helpers import effective_user
owner = effective_user(request)
@@ -782,7 +797,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
if len(session.history) < 6:
return {"status": "ok", "message": "Not enough messages to compact"}
ctx_len = get_context_length(session.endpoint_url, session.model)
context_kwargs = {}
if not capability.allow_live_probes:
context_kwargs["allow_live_probes"] = False
ctx_len = get_context_length(
session.endpoint_url,
session.model,
**context_kwargs,
)
messages_before = session.get_context_messages()
used_before = estimate_tokens(messages_before)
pct_before = round((used_before / ctx_len) * 100, 1) if ctx_len else 0
@@ -809,6 +831,9 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT, normalize_compaction_summary
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
compact_kwargs = {}
if not capability.allow_live_probes:
compact_kwargs["allow_live_probes"] = False
summary = await llm_call_async(
compact_url, compact_model,
[
@@ -817,6 +842,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
],
temperature=0.2, max_tokens=1024,
headers=compact_headers, timeout=30,
**compact_kwargs,
)
summary = normalize_compaction_summary(summary)
+8 -6
View File
@@ -10,7 +10,7 @@ import time
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
from services.search.core import _call_provider
from services.search.providers import _get_provider_key, _get_search_instance
from src.auth_helpers import require_chat_scope
from src.auth_helpers import require_interactive_request
logger = logging.getLogger(__name__)
@@ -40,11 +40,12 @@ async def _request_values(request: Request) -> Dict[str, Any]:
def setup_search_routes(config) -> APIRouter:
router = APIRouter(
tags=["search"],
dependencies=[Depends(require_chat_scope)],
dependencies=[Depends(require_interactive_request)],
)
@router.get("/api/search/config")
async def get_search_settings() -> Dict[str, Any]:
async def get_search_settings(request: Request) -> Dict[str, Any]:
require_interactive_request(request)
return get_search_config()
@router.post("/api/search")
@@ -53,7 +54,7 @@ def setup_search_routes(config) -> APIRouter:
Used by Compare mode to pre-search once and share results across panes.
"""
require_chat_scope(request)
require_interactive_request(request)
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
if not query:
@@ -71,8 +72,9 @@ def setup_search_routes(config) -> APIRouter:
return {"context": "", "sources": [], "error": str(e)}
@router.get("/api/search/providers")
async def list_search_providers():
async def list_search_providers(request: Request):
"""Return available search providers with config status."""
require_interactive_request(request)
providers = []
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
if pid == "disabled":
@@ -92,7 +94,7 @@ def setup_search_routes(config) -> APIRouter:
@router.post("/api/search/query")
async def search_with_provider(request: Request) -> Dict[str, Any]:
"""Search using a specific provider. Used by compare search mode."""
require_chat_scope(request)
require_interactive_request(request)
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
provider = str(values.get("provider") or "").strip()
+84 -51
View File
@@ -16,9 +16,14 @@ from src.auth_helpers import (
_auth_disabled,
is_bearer_principal,
owner_filter,
request_capability,
require_chat_scope,
require_interactive_request,
)
from src.message_metadata import (
normalize_client_message_role,
sanitize_client_message_metadata,
)
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
from src.upload_handler import reserve_message_upload_references
@@ -246,32 +251,37 @@ def setup_session_routes(
# session is current and won't delete the live one — this server-side
# purge exists only to catch ghosts the frontend missed (tab close,
# crash). Only clean up rows old enough to be definitely orphaned.
try:
from datetime import timedelta as _td
_cutoff = utcnow_naive() - _td(minutes=10)
_purge_db = SessionLocal()
# Listing is an owner-scoped read for bearer integrations. The legacy
# incognito cleanup query has no owner predicate and would otherwise
# let a chat token mutate another user's stale sessions before the
# owner-filtered result is assembled. Browser cleanup remains intact.
if not is_bearer_principal(request):
try:
from core.database import ChatMessage as _DbMsg
_ghosts = _purge_db.query(DbSession).filter(
DbSession.name.in_(("Nobody", "Incognito")),
DbSession.created_at < _cutoff,
).all()
for _g in _ghosts:
if active_incognito_id and _g.id == active_incognito_id:
continue
_purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
_purge_db.delete(_g)
if hasattr(session_manager, "delete_session"):
try:
session_manager.delete_session(_g.id)
except Exception:
pass
if _ghosts:
_purge_db.commit()
finally:
_purge_db.close()
except Exception:
pass
from datetime import timedelta as _td
_cutoff = utcnow_naive() - _td(minutes=10)
_purge_db = SessionLocal()
try:
from core.database import ChatMessage as _DbMsg
_ghosts = _purge_db.query(DbSession).filter(
DbSession.name.in_(("Nobody", "Incognito")),
DbSession.created_at < _cutoff,
).all()
for _g in _ghosts:
if active_incognito_id and _g.id == active_incognito_id:
continue
_purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
_purge_db.delete(_g)
if hasattr(session_manager, "delete_session"):
try:
session_manager.delete_session(_g.id)
except Exception:
pass
if _ghosts:
_purge_db.commit()
finally:
_purge_db.close()
except Exception:
pass
user_sessions = session_manager.get_sessions_for_user(user)
# Fetch folder info from DB for each session
db = SessionLocal()
@@ -354,6 +364,8 @@ def setup_session_routes(
endpoint_id: str = Form(""),
):
require_chat_scope(request)
capability = request_capability(request)
probe_kwargs = {} if capability.allow_live_probes else {"allow_live_probes": False}
skip_val = str(skip_validation).lower() == "true"
user = effective_user(request)
endpoint_api_key = ""
@@ -405,6 +417,7 @@ def setup_session_routes(
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
**probe_kwargs,
)
if not ids:
raise HTTPException(400, "Cannot reach /v1/models")
@@ -416,28 +429,35 @@ def setup_session_routes(
chat_ids = [m for m in ids if not any(p in m.lower() for p in _NON_CHAT)]
model_to_use = (chat_ids or ids)[0]
else:
from src.llm_core import list_model_ids
import os as _os
req_base = _os.path.basename(model_to_use.rstrip("/"))
avail = list_model_ids(
endpoint_url,
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
if not avail:
raise HTTPException(400, "Cannot reach /v1/models")
if model_to_use not in avail:
found = None
for a in avail:
if _os.path.basename(a.rstrip("/")) == req_base:
found = a
break
if not found:
raise HTTPException(400,
f"Model not found at server. Available: {', '.join(avail)}")
model_to_use = found
# A bearer with an explicit model is already using an owner-scoped
# registered endpoint (raw URLs are rejected above). Do not turn
# that synchronous session-creation request into a live catalog
# probe merely to validate a value the caller supplied. Interactive
# requests retain the existing catalog-backed validation.
if capability.allow_live_probes:
from src.llm_core import list_model_ids
import os as _os
req_base = _os.path.basename(model_to_use.rstrip("/"))
avail = list_model_ids(
endpoint_url,
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
**probe_kwargs,
)
if not avail:
raise HTTPException(400, "Cannot reach /v1/models")
if model_to_use not in avail:
found = None
for a in avail:
if _os.path.basename(a.rstrip("/")) == req_base:
found = a
break
if not found:
raise HTTPException(400,
f"Model not found at server. Available: {', '.join(avail)}")
model_to_use = found
sid = str(uuid.uuid4())
user = effective_user(request)
@@ -586,7 +606,7 @@ def setup_session_routes(
raise HTTPException(400, "Invalid message attachment metadata") from exc
for m in messages:
sess.add_message(ChatMessage(
m["role"],
normalize_client_message_role(m.get("role", "user"), default="user"),
m["content"],
metadata=sanitize_client_message_metadata(m.get("metadata")),
))
@@ -1002,6 +1022,7 @@ def setup_session_routes(
async def compact_session(request: Request, session_id: str):
"""Summarize older messages into one compacted history entry."""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
@@ -1046,6 +1067,9 @@ def setup_session_routes(
for m in older
)
try:
compact_kwargs = {}
if not capability.allow_live_probes:
compact_kwargs["allow_live_probes"] = False
summary = await llm_call_async(
url,
model,
@@ -1054,6 +1078,7 @@ def setup_session_routes(
max_tokens=1024,
headers=headers,
timeout=60,
**compact_kwargs,
)
except Exception as e:
logger.error("Manual compaction failed: %s", e)
@@ -1079,7 +1104,10 @@ def setup_session_routes(
"message_count": len(new_history),
}
@router.post("/sessions/auto-sort")
@router.post(
"/sessions/auto-sort",
dependencies=[Depends(require_interactive_request)],
)
def auto_sort_sessions(request: Request, skip_llm: bool = False):
"""Use AI to categorize all sessions into folders.
@@ -1089,6 +1117,7 @@ def setup_session_routes(
users can clean junk without spending tokens.
"""
require_chat_scope(request)
require_interactive_request(request)
from src.llm_core import llm_call
user = effective_user(request)
single_user_mode = not user and _auth_disabled()
@@ -1370,6 +1399,7 @@ def setup_session_routes(
async def get_context_info(request: Request, session_id: str):
"""Get the real context length for a session's model from the endpoint."""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id)
session = session_manager.get_session(session_id)
if not session:
@@ -1378,7 +1408,10 @@ def setup_session_routes(
return {"context_length": None}
try:
from src.model_context import get_context_length
ctx = get_context_length(session.endpoint_url, session.model)
context_kwargs = {}
if not capability.allow_live_probes:
context_kwargs["allow_live_probes"] = False
ctx = get_context_length(session.endpoint_url, session.model, **context_kwargs)
return {"context_length": ctx, "model": session.model}
except Exception:
return {"context_length": None}
+14 -2
View File
@@ -21,7 +21,12 @@ from core.database import (
Note,
Session as DbSession,
)
from src.auth_helpers import effective_user, require_chat_scope, require_non_bearer_request
from src.auth_helpers import (
effective_user,
is_bearer_principal,
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 (
@@ -379,7 +384,14 @@ def setup_upload_routes(upload_handler):
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
current_user = effective_user(request)
file_owner = info.get("owner") if info else None
if auth_configured:
if is_bearer_principal(request):
# A token owner is an owner-bound data principal, even when that
# owner is an administrator. Do not reuse the browser admin
# fallback for bearer downloads or an admin token can read another
# user's upload by ID.
if not current_user or file_owner != current_user:
raise HTTPException(404, "File not found")
elif auth_configured:
if not current_user:
raise HTTPException(403, "Access denied")
if file_owner != current_user and not auth_mgr.is_admin(current_user):
+48 -24
View File
@@ -2,6 +2,7 @@
import uuid
import logging
import json
from typing import Optional
import httpx
@@ -9,7 +10,12 @@ from fastapi import APIRouter, HTTPException, Request, Form
from pydantic import BaseModel, Field
from core.database import SessionLocal, Webhook, ModelEndpoint
from src.auth_helpers import is_bearer_principal, owner_filter, require_chat_scope
from src.auth_helpers import (
is_bearer_principal,
owner_filter,
request_capability,
require_chat_scope,
)
from src.url_security import validate_public_http_url
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
@@ -62,6 +68,35 @@ def _caller_owns_session(sess_owner, caller) -> bool:
return sess_owner == caller
def _cached_endpoint_model_ids(endpoint) -> list[str]:
"""Return model IDs already stored for a configured endpoint.
The synchronous bearer integration may use a cached model or the provider's
``auto`` alias, but it must not turn an ordinary chat request into a remote
catalog probe. Malformed/legacy cache shapes are treated as empty.
"""
raw = getattr(endpoint, "cached_models", None)
if not raw:
return []
try:
value = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, ValueError):
return []
if isinstance(value, dict):
value = value.get("data") or value.get("models") or []
if not isinstance(value, list):
return []
ids = []
for item in value:
if isinstance(item, str) and item.strip():
ids.append(item.strip())
elif isinstance(item, dict):
model_id = item.get("id") or item.get("name") or item.get("model")
if isinstance(model_id, str) and model_id.strip():
ids.append(model_id.strip())
return ids
def setup_webhook_routes(
webhook_manager: WebhookManager,
auth_manager,
@@ -240,12 +275,13 @@ def setup_webhook_routes(
if getattr(request.state, "api_token", False) is not True:
raise HTTPException(403, "This endpoint requires an API token")
token_owner = require_chat_scope(request)
capability = request_capability(request)
if not token_owner:
raise HTTPException(403, "API token has no owner")
from core.models import ChatMessage
from src.llm_core import llm_call_async
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
message = body.message.strip()
if not message:
@@ -337,28 +373,12 @@ def setup_webhook_routes(
raise HTTPException(500, "Could not resolve endpoint credentials")
if model == "auto":
try:
async with httpx.AsyncClient(timeout=5) as client:
models_url = build_models_url(base_url)
hdrs = build_headers(api_key, base_url)
if models_url:
resp = await client.get(models_url, headers=hdrs)
resp.raise_for_status()
data = resp.json()
items = data if isinstance(data, list) else (data.get("data") or [])
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
if not ids and isinstance(data, dict):
ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
import json as _json
ids = _json.loads(ep.cached_models or "[]")
model = ids[0] if ids else "auto"
except Exception:
raise HTTPException(500, "Could not discover models from endpoint")
# This route is bearer-only. Resolve auto from the endpoint's
# already persisted catalog and leave the provider alias in
# place when no cache exists; neither choice needs a new
# /models or /tags request during ordinary chat.
ids = _cached_endpoint_model_ids(ep)
model = ids[0] if ids else "auto"
if not session_manager:
raise HTTPException(500, "Session manager not available")
@@ -378,9 +398,13 @@ def setup_webhook_routes(
messages = [{"role": m.role, "content": m.content} for m in sess.history]
llm_kwargs = {}
if not capability.allow_live_probes:
llm_kwargs["allow_live_probes"] = False
reply = await llm_call_async(
sess.endpoint_url, sess.model, messages,
headers=sess.headers, timeout=120,
**llm_kwargs,
)
sess.add_message(ChatMessage("assistant", reply))
session_manager.save_sessions()