fix(security): close API token agent authorization gaps

This commit is contained in:
RaresKeY
2026-08-28 18:07:40 +00:00
parent aeff329c05
commit 9150a453b4
20 changed files with 907 additions and 114 deletions
+8 -4
View File
@@ -11,11 +11,11 @@ import json
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from core.database import SessionLocal, CrewMember, ScheduledTask
from src.auth_helpers import get_current_user
from src.auth_helpers import require_interactive_request
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.task_scheduler import compute_next_run
@@ -78,10 +78,14 @@ def _task_to_checkin_dict(t: ScheduledTask) -> dict:
def setup_assistant_routes(task_scheduler) -> APIRouter:
router = APIRouter(prefix="/api/assistant", tags=["assistant"])
router = APIRouter(
prefix="/api/assistant",
tags=["assistant"],
dependencies=[Depends(require_interactive_request)],
)
def _owner(request: Request) -> str:
owner = get_current_user(request)
owner = require_interactive_request(request)
if not owner:
raise HTTPException(status_code=401, detail="Not authenticated")
return owner
+1
View File
@@ -731,6 +731,7 @@ async def build_chat_context(
agent_mode=agent_mode,
incognito=incognito,
use_skills=skills_enabled,
allow_tool_preprocessing=allow_tool_preprocessing,
)
if use_rag is not None or is_research_spinoff or casual_low_signal:
_preface_kwargs["use_rag"] = use_rag_val
+84 -29
View File
@@ -9,7 +9,7 @@ import logging
from datetime import datetime
from typing import Dict, Any, AsyncGenerator, List, Optional
from fastapi import APIRouter, Request, HTTPException, Form, Query
from fastapi import APIRouter, Depends, Request, HTTPException, Form, Query
from fastapi.responses import StreamingResponse
from pydantic import ValidationError
@@ -45,6 +45,7 @@ from src.auth_helpers import (
enforce_api_token_chat_controls,
get_current_user,
require_chat_scope,
require_interactive_request,
)
from routes.session_routes import _verify_session_owner
from routes.document_helpers import _owner_session_filter
@@ -736,7 +737,7 @@ def setup_chat_routes(
webhook_manager=None,
skills_manager=None,
) -> APIRouter:
router = APIRouter(tags=["chat"])
router = APIRouter(tags=["chat"], dependencies=[Depends(require_chat_scope)])
# ------------------------------------------------------------------ #
# POST /api/chat (non-streaming)
@@ -754,6 +755,9 @@ def setup_chat_routes(
time_filter = chat_request.time_filter
preset_id = chat_request.preset_id
if getattr(request.state, "api_token", False) is True and use_research:
raise HTTPException(403, "API tokens cannot use research or agent execution")
# Verify the caller owns this session before loading it.
# Without this, any authenticated user can post into another user's chat.
_verify_session_owner(request, session)
@@ -782,15 +786,16 @@ def setup_chat_routes(
# non-streaming path can't be used to bypass).
_enforce_chat_privileges(request, sess)
api_token_request = getattr(request.state, "api_token", False) is True
tool_policy = build_effective_tool_policy(last_user_message=message)
allow_tool_preprocessing = (
not getattr(request.state, "api_token", False)
not api_token_request
and not tool_policy.block_all_tool_calls
)
# Inline memory command
memory_response = None
if not tool_policy.blocks("manage_memory"):
if allow_tool_preprocessing and not tool_policy.blocks("manage_memory"):
memory_response = await chat_handler.handle_memory_command(sess, message)
if memory_response:
return {"response": memory_response}
@@ -819,7 +824,7 @@ def setup_chat_routes(
tool_policy.blocks("trigger_research")
or tool_policy.blocks("manage_research")
)
if use_research and not research_blocked_by_policy:
if use_research and not api_token_request and not research_blocked_by_policy:
try:
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
research_ctx = await research_handler.call_research_service(
@@ -919,7 +924,7 @@ def setup_chat_routes(
ctx.uprefs, memory_manager, memory_vector, webhook_manager,
character_name=ctx.preset.character_name,
owner=ctx.user,
allow_background_extraction=not tool_policy.block_all_tool_calls,
allow_background_extraction=allow_tool_preprocessing,
)
return {
@@ -958,6 +963,8 @@ def setup_chat_routes(
attachments = form_data.get("attachments")
use_web = form_data.get("use_web")
use_research = form_data.get("use_research")
if use_research is None:
use_research = (body or {}).get("use_research")
time_filter = form_data.get("time_filter")
preset_id = form_data.get("preset_id")
selected_endpoint_id = str(
@@ -996,10 +1003,35 @@ def setup_chat_routes(
approval_id=tool_approval_id,
allow_bash=allow_bash,
)
# A bearer token is a chat-only integration credential. Reject every
# remaining control-plane input before approval lookup, intent
# detection, context shaping, or active-email/workspace resolution can
# turn the request into an interactive agent turn.
approved_plan = ""
if not plan_mode:
approved_plan = str(
form_data.get("approved_plan")
or (body or {}).get("approved_plan")
or ""
).strip()[:8192]
if api_token_request and (
str(use_research).lower() == "true"
or bool(tool_approval_decision)
or bool(approved_plan)
):
raise HTTPException(
403,
"API tokens cannot use research, agent, plan, or tool approvals",
)
# Workspace: confine the agent's file/shell tools to this folder.
workspace, workspace_rejected = _resolve_request_workspace(
request, form_data.get("workspace")
)
if api_token_request:
workspace, workspace_rejected = "", ""
else:
workspace, workspace_rejected = _resolve_request_workspace(
request, form_data.get("workspace")
)
# Plan mode is a modifier on agent mode — it only makes sense with tools.
if plan_mode:
chat_mode = "agent"
@@ -1008,9 +1040,6 @@ def setup_chat_routes(
# weak model survives history truncation — the agent can always re-read
# the plan. Ignored while still proposing (plan_mode on). Capped so a
# huge plan can't blow the prompt.
approved_plan = ""
if not plan_mode:
approved_plan = (form_data.get("approved_plan") or "").strip()[:8192]
# Did the USER explicitly pick agent mode? (vs. us auto-escalating
# below). Skill extraction should only learn from real agent sessions,
# not chats we quietly promoted for a notes/calendar intent.
@@ -1045,7 +1074,7 @@ def setup_chat_routes(
auto_escalated = False
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
_workspace_agent_intent = False
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
if not api_token_request and chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
chat_mode = "agent"
auto_escalated = True
_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}
@@ -1056,24 +1085,28 @@ def setup_chat_routes(
_tool_intent.category,
_tool_intent.reason,
)
elif chat_mode == "chat" and _search_enabled:
elif not api_token_request and chat_mode == "chat" and _search_enabled:
chat_mode = "agent"
auto_escalated = True
logger.info("chat→agent auto-escalation: search enabled")
elif chat_mode == "chat" and _explicit_web_intent:
elif not api_token_request and chat_mode == "chat" and _explicit_web_intent:
chat_mode = "agent"
auto_escalated = True
logger.info("chat→agent auto-escalation: explicit web intent")
active_doc_id = form_data.get("active_doc_id", "").strip()
active_doc_id = "" if api_token_request else str(form_data.get("active_doc_id") or "").strip()
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
# Active email reader — when the user has an email open in the UI, the
# frontend passes its uid/folder/account so "reply", "summarize this",
# etc. resolve to the real email instead of the agent inventing a
# fake markdown draft.
active_email_uid = form_data.get("active_email_uid", "").strip()
active_email_folder = form_data.get("active_email_folder", "INBOX").strip() or "INBOX"
active_email_account = form_data.get("active_email_account", "").strip()
active_email_uid = "" if api_token_request else str(form_data.get("active_email_uid") or "").strip()
active_email_folder = (
"INBOX"
if api_token_request
else str(form_data.get("active_email_folder") or "INBOX").strip() or "INBOX"
)
active_email_account = "" if api_token_request else str(form_data.get("active_email_account") or "").strip()
active_email_ctx: Optional[Dict[str, str]] = None
# Always reset between requests so a stale active-email pointer from
# a previous turn (different reader closed, different account, etc.)
@@ -1246,7 +1279,8 @@ def setup_chat_routes(
if not (getattr(sess, "endpoint_url", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
if (
chat_mode == "chat"
not api_token_request
and chat_mode == "chat"
and isinstance(message, str)
and (not _tool_intent or not _tool_intent.needs_tools)
and _is_contextual_web_followup(message, sess)
@@ -1260,14 +1294,14 @@ def setup_chat_routes(
_tool_intent.category,
_tool_intent.reason,
)
if isinstance(message, str) and _is_contextual_browser_followup(message, sess):
if not api_token_request and isinstance(message, str) and _is_contextual_browser_followup(message, sess):
_explicit_browser_intent = True
if chat_mode == "chat":
chat_mode = "agent"
auto_escalated = True
_workspace_agent_intent = False
logger.info("chat→agent auto-escalation: contextual browser/form follow-up")
if not workspace and isinstance(message, str):
if not api_token_request and not workspace and isinstance(message, str):
_auto_workspace, _ = _resolve_workspace_from_message_path(request, message)
if _auto_workspace:
workspace = _auto_workspace
@@ -1309,6 +1343,8 @@ def setup_chat_routes(
# mutable form fields, or a stale research_pending session marker,
# consume the one-use grant on the unrelated research path.
do_research = (
not api_token_request
and
not tool_approval_continuation
and str(use_research).lower() == "true"
)
@@ -1330,7 +1366,11 @@ def setup_chat_routes(
except Exception as e:
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
image_generation_session = _is_image_generation_session(sess, owner=effective_user(request))
image_generation_session = _is_image_generation_session(
sess, owner=effective_user(request)
)
if api_token_request and image_generation_session:
raise HTTPException(403, "API tokens cannot use image generation")
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
if image_generation_session:
no_memory = True
@@ -1845,7 +1885,7 @@ def setup_chat_routes(
yield f'data: {json.dumps(_model_info)}\n\n'
_terminal_saved = False
if _is_image_generation_session(sess, owner=_user):
if image_generation_session:
from src.settings import get_setting
if tool_policy.blocks("generate_image"):
_blocked_msg = tool_policy.reason_for("generate_image")
@@ -2256,7 +2296,7 @@ def setup_chat_routes(
character_name=ctx.preset.character_name,
owner=_user,
allow_background_extraction=(
not tool_policy.block_all_tool_calls
allow_tool_preprocessing
and not tool_approval_continuation
),
)
@@ -2530,7 +2570,7 @@ def setup_chat_routes(
and not tool_approval_continuation
),
allow_background_extraction=(
not tool_policy.block_all_tool_calls
allow_tool_preprocessing
and not tool_approval_continuation
),
)
@@ -2621,8 +2661,12 @@ def setup_chat_routes(
# GET /api/chat/resume — reconnect to a detached run that's still going
# (e.g. after reopening a session whose agent kept running in the background)
# ------------------------------------------------------------------ #
@router.get("/api/chat/resume/{session_id}")
@router.get(
"/api/chat/resume/{session_id}",
dependencies=[Depends(require_interactive_request)],
)
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
require_interactive_request(request)
_verify_session_owner(request, session_id)
_active_run = agent_runs.get_active_run(session_id)
if _active_run is None:
@@ -2637,8 +2681,12 @@ def setup_chat_routes(
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
# no longer stops it (it's detached), so the Stop button must call this.
# ------------------------------------------------------------------ #
@router.post("/api/chat/stop/{session_id}")
@router.post(
"/api/chat/stop/{session_id}",
dependencies=[Depends(require_interactive_request)],
)
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
require_interactive_request(request)
_verify_session_owner(request, session_id)
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
stopped = agent_runs.stop(session_id, _expected_run_id)
@@ -2647,8 +2695,12 @@ def setup_chat_routes(
# ------------------------------------------------------------------ #
# GET /api/chat/stream_status — check if a stream is active for a session
# ------------------------------------------------------------------ #
@router.get("/api/chat/stream_status/{session_id}")
@router.get(
"/api/chat/stream_status/{session_id}",
dependencies=[Depends(require_interactive_request)],
)
async def chat_stream_status(request: Request, session_id: str) -> Dict[str, Any]:
require_interactive_request(request)
_verify_session_owner(request, session_id)
# A detached run can still be going even if _active_streams was popped;
# report it as active so the client knows to reconnect via /resume.
@@ -2667,6 +2719,7 @@ def setup_chat_routes(
# ------------------------------------------------------------------ #
@router.post("/api/inject_context/{session_id}")
async def inject_context(request: Request, session_id: str, context: str = Form(...)) -> Dict[str, str]:
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
sess = session_manager.get_session(session_id)
@@ -2686,6 +2739,7 @@ def setup_chat_routes(
q: str = Query("", min_length=0),
limit: int = Query(20, ge=1, le=100),
) -> List[Dict[str, Any]]:
require_chat_scope(request)
if not q or not q.strip():
return []
@@ -2711,6 +2765,7 @@ def setup_chat_routes(
Unlike the full chat pipeline, this does NOT run the agent loop or tools.
It just asks the LLM to rewrite the given text.
"""
require_chat_scope(request)
try:
body = await request.json()
except Exception:
+11 -13
View File
@@ -16,7 +16,11 @@ from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from core.middleware import require_admin
from src.auth_helpers import require_authenticated_request, require_user
from src.auth_helpers import (
require_api_token_owner,
require_authenticated_request,
require_user,
)
from src.tool_implementations import do_manage_notes
from src.constants import COOKBOOK_STATE_FILE
from routes._validators import validate_remote_host, validate_ssh_port
@@ -84,29 +88,23 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
def _scope_owner(request: Request, allowed: set[str]) -> str:
"""Return the data owner if the caller is allowed for this Codex action."""
if getattr(request.state, "api_token", False):
if getattr(request.state, "api_token", False) is True:
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if not scopes.intersection(allowed):
required = " or ".join(sorted(allowed))
raise HTTPException(403, f"API token missing required scope: {required}")
owner = getattr(request.state, "api_token_owner", None)
if not owner:
raise HTTPException(403, "API token has no owner")
return owner
return require_api_token_owner(request)
return require_user(request)
def _scope_owner_all(request: Request, required: set[str]) -> str:
"""Return owner only when an API token has every required scope."""
if getattr(request.state, "api_token", False):
if getattr(request.state, "api_token", False) is True:
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
missing = required - scopes
if missing:
raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}")
owner = getattr(request.state, "api_token_owner", None)
if not owner:
raise HTTPException(403, "API token has no owner")
return owner
return require_api_token_owner(request)
return require_user(request)
@@ -119,7 +117,7 @@ def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
commands, and model-serving controls.
"""
owner = _scope_owner(request, allowed)
if not getattr(request.state, "api_token", False):
if getattr(request.state, "api_token", False) is not True:
require_admin(request)
return owner
@@ -167,7 +165,7 @@ def setup_codex_routes(
@router.get("/capabilities")
def capabilities(request: Request):
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
has_token = bool(getattr(request.state, "api_token", False))
has_token = getattr(request.state, "api_token", False) is True
def scoped(allowed):
return bool(token_scopes.intersection(allowed)) if has_token else True
return {
+13 -1
View File
@@ -145,6 +145,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> Dict[str, Any]:
require_chat_scope(request)
_verify_session_owner(request, session_id)
if limit is not None:
page_limit = max(1, min(int(limit), 100))
@@ -247,6 +248,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/truncate")
async def truncate_session(request: Request, session_id: str):
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -262,6 +264,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/message")
async def add_message(request: Request, session_id: str):
"""Add a message to a session (for slash command persistence)."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -280,6 +283,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/delete-messages")
async def delete_messages(request: Request, session_id: str):
"""Delete specific messages by DB ID (or legacy index)."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -343,6 +347,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/edit-message")
async def edit_message(request: Request, session_id: str):
"""Edit the content of a message by its database ID."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -398,6 +403,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/mark-stopped")
async def mark_stopped(request: Request, session_id: str):
"""Mark the last assistant message as stopped by user."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
@@ -453,10 +459,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/update-last-meta")
async def update_last_meta(request: Request, session_id: str):
"""Merge metadata into the last assistant message (e.g. save variants)."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
meta_update = sanitize_client_message_metadata(body.get("metadata", {}))
meta_update = sanitize_client_message_metadata(body.get("metadata", {})) or {}
session = session_manager.get_session(session_id)
# Update in-memory
@@ -504,6 +511,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/merge-last-assistant")
async def merge_last_assistant(request: Request, session_id: str):
"""Merge the last two assistant messages into one (for continue)."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -593,6 +601,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/fork")
async def fork_session(request: Request, session_id: str):
"""Create a new session with messages copied up to keep_count."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -651,6 +660,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.get("/api/conversations/topics")
async def get_conversation_topics(request: Request) -> Dict[str, Any]:
require_chat_scope(request)
from src.auth_helpers import require_user
user = require_user(request)
try:
@@ -666,6 +676,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
endpoint estimates the persisted session context so the header can show
when the whole chat is approaching compaction.
"""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
@@ -710,6 +721,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/compact")
async def compact_session(request: Request, session_id: str):
"""Manually trigger context compaction for a session."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
from src.auth_helpers import effective_user
owner = effective_user(request)
+2 -8
View File
@@ -29,7 +29,7 @@ from src.endpoint_resolver import (
build_models_url,
build_headers,
)
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
from src.auth_helpers import _auth_disabled, owner_filter, require_chat_scope
logger = logging.getLogger(__name__)
@@ -1622,13 +1622,7 @@ def setup_model_routes(model_discovery):
# Require auth; "" is the unconfigured single-user mode, treated as
# "see everything" by _fetch_models.
try:
if getattr(request.state, "api_token", False):
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if "chat" not in scopes:
raise HTTPException(403, "API token is not scoped for chat")
if not getattr(request.state, "api_token_owner", None):
raise HTTPException(403, "API token has no owner")
owner = effective_user(request) or ""
owner = require_chat_scope(request) or ""
# Reject anonymous in configured deployments — no leaking the model
# list to unauthenticated callers.
+7 -4
View File
@@ -9,12 +9,12 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel, Field
from core.middleware import INTERNAL_TOOL_USER
from src.endpoint_resolver import resolve_endpoint
from src.auth_helpers import _auth_disabled, get_current_user
from src.auth_helpers import _auth_disabled, require_interactive_request
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.constants import DEEP_RESEARCH_DIR
@@ -207,14 +207,17 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
router = APIRouter(tags=["research"])
router = APIRouter(
tags=["research"],
dependencies=[Depends(require_interactive_request)],
)
def _require_user(request: Request) -> str:
"""All research endpoints require an authenticated user. Research
data isn't owner-scoped in the on-disk JSON yet, so we at least
block anonymous access. Multi-tenant deploys should additionally
verify the session belongs to this user."""
user = get_current_user(request)
user = require_interactive_request(request)
if not user:
if _auth_disabled():
return ""
+8 -2
View File
@@ -3,13 +3,14 @@
import logging
from typing import Dict, Any
from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Request
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
logger = logging.getLogger(__name__)
@@ -37,7 +38,10 @@ async def _request_values(request: Request) -> Dict[str, Any]:
def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
router = APIRouter(
tags=["search"],
dependencies=[Depends(require_chat_scope)],
)
@router.get("/api/search/config")
async def get_search_settings() -> Dict[str, Any]:
@@ -49,6 +53,7 @@ def setup_search_routes(config) -> APIRouter:
Used by Compare mode to pre-search once and share results across panes.
"""
require_chat_scope(request)
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
if not query:
@@ -87,6 +92,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)
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
provider = str(values.get("provider") or "").strip()
+18
View File
@@ -225,6 +225,7 @@ def setup_session_routes(
@router.get("/sessions")
def list_sessions(request: Request):
require_chat_scope(request)
user = effective_user(request)
active_incognito_id = str(request.query_params.get("active_incognito_id") or "").strip()
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
@@ -343,6 +344,7 @@ def setup_session_routes(
api_key: str = Form(""),
endpoint_id: str = Form(""),
):
require_chat_scope(request)
skip_val = str(skip_validation).lower() == "true"
user = effective_user(request)
endpoint_api_key = ""
@@ -470,6 +472,7 @@ def setup_session_routes(
model: str = Form(None), endpoint_url: str = Form(None),
endpoint_id: str = Form(None),
):
require_chat_scope(request)
_verify_session_owner(request, sid)
try:
session = session_manager.get_session(sid)
@@ -544,6 +547,7 @@ def setup_session_routes(
@router.post("/session/{sid}/inject_messages")
async def inject_messages(request: Request, sid: str):
"""Bulk-inject messages into a session's history (for group chat sync)."""
require_chat_scope(request)
_verify_session_owner(request, sid)
try:
sess = session_manager.get_session(sid)
@@ -580,11 +584,13 @@ def setup_session_routes(
@router.post("/session/{sid}/delete")
def delete_session_beacon(request: Request, sid: str):
"""Delete session via POST (for navigator.sendBeacon on page close)."""
require_chat_scope(request)
return delete_session(request, sid)
@router.post("/sessions/bulk-delete")
async def bulk_delete_sessions(request: Request):
"""Delete multiple sessions (for compare cleanup via sendBeacon)."""
require_chat_scope(request)
from core.database import ChatMessage as _CM
try:
body = await request.json()
@@ -614,6 +620,7 @@ def setup_session_routes(
@router.delete("/session/{sid}")
def delete_session(request: Request, sid: str):
"""Permanently delete a session and all its messages."""
require_chat_scope(request)
_verify_session_owner(request, sid, session_manager)
try:
# Block deletion of starred/favorited sessions
@@ -648,6 +655,7 @@ def setup_session_routes(
@router.delete("/sessions/all")
def delete_all_sessions(request: Request):
"""Admin only: permanently delete ALL sessions and their messages."""
require_chat_scope(request)
from core.middleware import require_admin
require_admin(request)
@@ -701,6 +709,7 @@ def setup_session_routes(
@router.post("/session/{sid}/archive")
def archive_session(request: Request, sid: str):
"""Archive a session, keeping its data but removing it from active sessions."""
require_chat_scope(request)
_verify_session_owner(request, sid)
try:
# First check if session exists
@@ -739,6 +748,7 @@ def setup_session_routes(
@router.post("/session/{sid}/unarchive")
def unarchive_session(request: Request, sid: str):
"""Restore an archived session back to the active session list."""
require_chat_scope(request)
_verify_session_owner(request, sid)
db = SessionLocal()
try:
@@ -769,6 +779,7 @@ def setup_session_routes(
@router.get("/sessions/archived")
def list_archived_sessions(request: Request, search: str = "", offset: int = 0, limit: int = 20, sort: str = "recent", model: str = ""):
"""List archived sessions for the archive browser."""
require_chat_scope(request)
user = effective_user(request)
db = SessionLocal()
try:
@@ -816,6 +827,7 @@ def setup_session_routes(
Supported formats: md (markdown), txt (plain text), json, html
"""
require_chat_scope(request)
_verify_session_owner(request, sid)
try:
session = session_manager.get_session(sid)
@@ -902,6 +914,7 @@ def setup_session_routes(
@router.post("/sessions/save")
def sessions_save_now(request: Request):
require_chat_scope(request)
user = effective_user(request)
if not user:
raise HTTPException(401, "Not authenticated")
@@ -915,6 +928,7 @@ def setup_session_routes(
model: str = Form("gpt-4o"),
rag: str = Form(None)
):
require_chat_scope(request)
if not OPENAI_API_KEY:
raise HTTPException(400, "Server missing OPENAI_API_KEY")
sid = str(uuid.uuid4())
@@ -936,6 +950,7 @@ def setup_session_routes(
@router.post("/session/{session_id}/important")
async def mark_session_important(request: Request, session_id: str, important: bool = Form(True)):
"""Mark a session as important to protect it from automatic cleanup."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
# Validate session exists
@@ -973,6 +988,7 @@ def setup_session_routes(
@router.post("/session/{session_id}/compact")
async def compact_session(request: Request, session_id: str):
"""Summarize older messages into one compacted history entry."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
@@ -1059,6 +1075,7 @@ def setup_session_routes(
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens.
"""
require_chat_scope(request)
from src.llm_core import llm_call
user = effective_user(request)
single_user_mode = not user and _auth_disabled()
@@ -1339,6 +1356,7 @@ def setup_session_routes(
@router.get("/session/{session_id}/context_info")
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)
_verify_session_owner(request, session_id)
session = session_manager.get_session(session_id)
if not session:
+8 -4
View File
@@ -13,11 +13,11 @@ from typing import List, Optional
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user
from src.auth_helpers import require_interactive_request
from src.prompt_security import untrusted_context_message
from core.middleware import require_admin
@@ -1181,10 +1181,14 @@ async def run_scheduled_skill_audit(skills_manager: SkillsManager,
def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
router = APIRouter(prefix="/api/skills", tags=["skills"])
router = APIRouter(
prefix="/api/skills",
tags=["skills"],
dependencies=[Depends(require_interactive_request)],
)
def _owner(request: Request) -> Optional[str]:
return get_current_user(request)
return require_interactive_request(request)
def _verify_owner(skill: dict, user: Optional[str]):
if user is None:
+11 -3
View File
@@ -7,12 +7,12 @@ import uuid
from datetime import datetime
from typing import Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from core.database import SessionLocal, ScheduledTask, TaskRun
from core.constants import internal_api_base
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, require_interactive_request
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
from src.task_action_policy import (
ADMIN_ONLY_TASK_ACTIONS,
@@ -296,9 +296,17 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
def setup_task_routes(task_scheduler) -> APIRouter:
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
router = APIRouter(
prefix="/api/tasks",
tags=["tasks"],
dependencies=[Depends(require_interactive_request)],
)
def _owner(request: Request):
# Keep the route-local user lookup injectable for direct handler tests
# and legacy callers, but always run the centralized bearer-principal
# rejection first.
require_interactive_request(request)
return get_current_user(request)
async def _generate_task_name(prompt: str, owner: Optional[str] = None) -> str:
+6
View File
@@ -265,6 +265,7 @@ def setup_upload_routes(upload_handler):
session_id: Optional[str] = Form(None),
):
"""Upload files with enhanced security and organization."""
require_chat_scope(request)
if not isinstance(session_id, str):
session_id = None
if not files:
@@ -324,6 +325,7 @@ def setup_upload_routes(upload_handler):
@router.post("/cleanup")
async def manual_cleanup(request: Request):
"""Manually trigger cleanup of old uploads."""
require_chat_scope(request)
require_admin(request)
try:
cleaned_count = await asyncio.to_thread(
@@ -347,6 +349,7 @@ def setup_upload_routes(upload_handler):
@router.get("/stats")
async def upload_stats(request: Request):
"""Get statistics about uploaded files."""
require_chat_scope(request)
require_admin(request)
try:
return upload_handler.get_upload_stats()
@@ -359,6 +362,7 @@ def setup_upload_routes(upload_handler):
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
JPEG thumbnail for images (used by chat attachment previews) so the
client isn't downloading the full-resolution photo just to show it tiny."""
require_chat_scope(request)
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
import mimetypes as _mt
@@ -457,6 +461,7 @@ def setup_upload_routes(upload_handler):
"""Return the vision-model OCR/description for an uploaded image.
Cached under UPLOAD_DIR/.vision/{file_id}.txt — first call computes,
subsequent loads are instant. Pass force=1 to recompute."""
require_chat_scope(request)
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
info = _load_upload_info(file_id)
@@ -501,6 +506,7 @@ def setup_upload_routes(upload_handler):
async def put_vision_text(request: Request, file_id: str):
"""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)
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
info = _load_upload_info(file_id)
+11 -11
View File
@@ -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
from src.auth_helpers import 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
@@ -32,14 +32,15 @@ def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
let a chat-scoped token fall back onto another user's private endpoint and
silently spend that owner's API key/quota. Prefer owner rows before shared
rows. Fails closed to null-owner rows only when token_owner is absent.
rows. Fails closed when token_owner is absent; the sync endpoint requires
an owner-scoped bearer before this helper is reached.
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
"""
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
if token_owner:
query = owner_filter(query, ModelEndpoint, token_owner)
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
if not token_owner:
return None
query = owner_filter(query, ModelEndpoint, token_owner)
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
def _caller_owns_session(sess_owner, caller) -> bool:
@@ -236,12 +237,11 @@ def setup_webhook_routes(
@router.post("/v1/chat")
async def sync_chat(request: Request, body: SyncChatRequest):
if not getattr(request.state, "api_token", False):
if getattr(request.state, "api_token", False) is not True:
raise HTTPException(403, "This endpoint requires an API token")
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if "chat" not in scopes:
raise HTTPException(403, "API token is not scoped for chat")
token_owner = getattr(request.state, "api_token_owner", None)
token_owner = require_chat_scope(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