From a09b0b5722c1e140f9d733ad5f0de4389610cce6 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:52:35 +0000 Subject: [PATCH] fix(security): close bearer alias boundaries --- routes/chat_helpers.py | 44 ++ routes/chat_routes.py | 17 +- routes/codex_routes.py | 41 +- routes/compare/compare_routes.py | 82 ++- routes/gallery/gallery_routes.py | 130 +++-- routes/model_routes.py | 70 ++- routes/session_routes.py | 10 +- routes/webhook/webhook_routes.py | 49 +- src/chatgpt_subscription.py | 12 +- src/endpoint_resolver.py | 120 ++++- src/foreground_model_routing.py | 70 ++- tests/test_api_token_cycle6_repair.py | 658 ++++++++++++++++++++++++ tests/test_api_token_security_cycle4.py | 4 + 13 files changed, 1200 insertions(+), 107 deletions(-) create mode 100644 tests/test_api_token_cycle6_repair.py diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index 1e07ea691..256da4fb7 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -652,6 +652,50 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]: return None +def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[str]: + """Enforce endpoint-picker authority for a bearer session model. + + Direct API-key sessions intentionally have no ``ModelEndpoint`` row and + retain their documented compatibility behavior. Registered endpoint + sessions, including provider-auth-backed rows, must use the visible + server-owned inventory and never trigger a provider lookup here. + """ + endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip() + requested = (getattr(sess, "model", "") or "").strip() + if not endpoint_url or not requested: + return None + try: + session_base = normalize_base(endpoint_url) + except Exception: + session_base = endpoint_url.rstrip("/") + if not session_base: + return None + + db = SessionLocal() + try: + q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + if owner: + from src.auth_helpers import owner_filter + + q = owner_filter(q, ModelEndpoint, owner) + for ep in q.all(): + try: + if normalize_base(getattr(ep, "base_url", "") or "") != session_base: + continue + except Exception: + continue + from routes.model_routes import _validate_bearer_model_selection + + sess.model = _validate_bearer_model_selection(ep, requested) + return sess.model + finally: + db.close() + + # No registered endpoint means this is the documented direct API-key + # compatibility path, not an endpoint-picker selection. + return None + + def _session_is_research_spinoff(sess) -> bool: """True if this session was created via research "Discuss" spin-off. diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 6d2fdee6b..8019be436 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -63,6 +63,7 @@ from routes.model_routes import ( from routes.chat_helpers import ( resolve_session_auth, build_chat_context, + _validate_bearer_session_model, save_assistant_response, run_post_response_tasks, accumulate_token_usage, @@ -847,6 +848,8 @@ def setup_chat_routes( ) if not (getattr(sess, "endpoint_url", "") or "").strip(): raise HTTPException(400, "Selected model endpoint is not configured") + if request_capability.is_bearer: + _validate_bearer_session_model(sess, owner=owner) # Same allowed_models + daily-cap gate as chat_stream (mirror so the # non-streaming path can't be used to bypass). @@ -869,6 +872,7 @@ def setup_chat_routes( foreground_policy = resolve_foreground_model_policy( owner=owner, allowed_models=_allowed_models_for_request(request), + allow_live_probes=request_capability.allow_live_probes, ) # Build shared context (preset, preprocess, preface, compact) @@ -913,6 +917,7 @@ def setup_chat_routes( sess.headers, owner=owner, policy=foreground_policy, + allow_live_probes=request_capability.allow_live_probes, ) route_descriptors = build_foreground_route_descriptors( sess.endpoint_url, @@ -921,6 +926,7 @@ def setup_chat_routes( owner=owner, policy=foreground_policy, selected_endpoint_id=chat_request.selected_endpoint_id, + allow_live_probes=request_capability.allow_live_probes, ) candidate_request_factory = None selected_context_length = getattr(ctx, "context_length", 0) @@ -1151,7 +1157,11 @@ def setup_chat_routes( # its way through a plain chat request (and fail, especially with the # shell disabled). auto_escalated = False - _tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None + _tool_intent = ( + _classify_tool_intent(message) + if not api_token_request and isinstance(message, str) + else None + ) _workspace_agent_intent = False if not api_token_request and chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools: chat_mode = "agent" @@ -1388,6 +1398,8 @@ def setup_chat_routes( ) if not (getattr(sess, "endpoint_url", "") or "").strip(): raise HTTPException(400, "Selected model endpoint is not configured") + if request_capability.is_bearer: + _validate_bearer_session_model(sess, owner=owner) if ( not api_token_request and chat_mode == "chat" @@ -1501,6 +1513,7 @@ def setup_chat_routes( foreground_policy = resolve_foreground_model_policy( owner=owner, allowed_models=_allowed_models_for_request(request), + allow_live_probes=request_capability.allow_live_probes, ) # Build shared context (stream path uses enhanced_message for context preface) @@ -1966,6 +1979,7 @@ def setup_chat_routes( sess.headers, owner=_user, policy=_foreground_policy, + allow_live_probes=request_capability.allow_live_probes, ) _foreground_route_descriptors = build_foreground_route_descriptors( sess.endpoint_url, @@ -1974,6 +1988,7 @@ def setup_chat_routes( owner=_user, policy=_foreground_policy, selected_endpoint_id=selected_endpoint_id, + allow_live_probes=request_capability.allow_live_probes, ) _chat_request_factory = None _selected_context_length = getattr(ctx, "context_length", 0) diff --git a/routes/codex_routes.py b/routes/codex_routes.py index 3f84aea8b..e2ed71106 100644 --- a/routes/codex_routes.py +++ b/routes/codex_routes.py @@ -67,9 +67,40 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs): """Run an existing route handler with request.state.current_user temporarily set to ``owner`` so its internal get_current_user/require_user calls see the scope-gated owner (not the "api" pseudo-user the bearer middleware sets). - Restores the original value when done. Works for sync and async handlers.""" + Temporarily hide the bearer header as well: nested legacy handlers classify + the raw header independently of ``request.state.api_token``. Restore every + request value when done. Works for sync and async handlers.""" orig = getattr(request.state, "current_user", None) orig_api_token = getattr(request.state, "api_token", None) + missing = object() + scope = getattr(request, "scope", None) + original_scope_headers = missing + original_cached_headers = missing + original_mapping_headers = missing + + if isinstance(scope, dict) and "headers" in scope: + original_scope_headers = scope["headers"] + scope["headers"] = [ + (name, value) + for name, value in (original_scope_headers or []) + if not ( + (isinstance(name, bytes) and name.lower() == b"authorization") + or (isinstance(name, str) and name.casefold() == "authorization") + ) + ] + request_dict = getattr(request, "__dict__", {}) + if "_headers" in request_dict: + original_cached_headers = request_dict["_headers"] + request_dict.pop("_headers", None) + else: + current_headers = getattr(request, "headers", missing) + if isinstance(current_headers, dict): + original_mapping_headers = current_headers + request.headers = { + name: value + for name, value in current_headers.items() + if str(name).casefold() != "authorization" + } request.state.current_user = owner request.state.api_token = False try: @@ -86,6 +117,14 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs): pass else: request.state.api_token = orig_api_token + if original_scope_headers is not missing: + scope["headers"] = original_scope_headers + request_dict = getattr(request, "__dict__", {}) + request_dict.pop("_headers", None) + if original_cached_headers is not missing: + request_dict["_headers"] = original_cached_headers + if original_mapping_headers is not missing: + request.headers = original_mapping_headers def _scope_owner(request: Request, allowed: set[str]) -> str: diff --git a/routes/compare/compare_routes.py b/routes/compare/compare_routes.py index ad42f1a89..961e6dd29 100644 --- a/routes/compare/compare_routes.py +++ b/routes/compare/compare_routes.py @@ -4,19 +4,23 @@ import json import uuid import random from datetime import datetime -from fastapi import APIRouter, Form, HTTPException, Request +from fastapi import APIRouter, Depends, Form, HTTPException, Request from typing import List from pydantic import BaseModel import logging from core.database import Comparison, SessionLocal from core.session_manager import SessionManager -from src.auth_helpers import get_current_user +from src.auth_helpers import effective_user, is_bearer_principal, require_chat_scope from routes.session_routes import _reject_raw_endpoint_url_for_non_admin logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/compare", tags=["compare"]) +router = APIRouter( + prefix="/api/compare", + tags=["compare"], + dependencies=[Depends(require_chat_scope)], +) def _owned_endpoint_by_url(db, base_url, owner): @@ -64,6 +68,37 @@ class RecordVoteRequest(BaseModel): is_blind: bool = True +def _validate_bearer_compare_models(models, owner: str) -> list[str]: + """Validate record-only comparison models against visible endpoint caches.""" + from core.database import ModelEndpoint + from src.auth_helpers import owner_filter + from routes.model_routes import _validate_bearer_model_selection + + db = SessionLocal() + try: + q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + q = owner_filter(q, ModelEndpoint, owner) + endpoints = q.all() + selected = [] + for requested in models: + matches = [] + for ep in endpoints: + try: + matches.append(_validate_bearer_model_selection(ep, requested)) + except HTTPException: + continue + unique = list(dict.fromkeys(matches)) + if len(unique) != 1: + raise HTTPException( + 400, + f"Model is not permitted by a visible server endpoint: {requested}", + ) + selected.append(unique[0]) + return selected + finally: + db.close() + + def setup_compare_routes(session_manager: SessionManager): """Setup comparison routes.""" @@ -84,7 +119,9 @@ def setup_compare_routes(session_manager: SessionManager): Returns the comparison ID and the two session IDs so the client can fire two independent SSE streams to /api/chat_stream. """ - user = getattr(request.state, 'current_user', None) + require_chat_scope(request) + user = effective_user(request) + bearer = is_bearer_principal(request) comp_id = str(uuid.uuid4()) sid_a = str(uuid.uuid4()) sid_b = str(uuid.uuid4()) @@ -160,6 +197,13 @@ def setup_compare_routes(session_manager: SessionManager): _reject_raw_endpoint_url_for_non_admin( request, user, str(ep.id) if ep is not None else None, endpoint ) + selected_model = model + if bearer: + if ep is None: + raise HTTPException(403, "Choose a registered model endpoint") + from routes.model_routes import _validate_bearer_model_selection + + selected_model = _validate_bearer_model_selection(ep, model) # Bind the [CMP] session to the RESOLVED endpoint, not the raw # caller-supplied string. When the URL matches a registered # endpoint visible to the caller, use that row's own normalized @@ -176,7 +220,7 @@ def setup_compare_routes(session_manager: SessionManager): # `ep` is None (raw admin URL or no match), so a comparison can # never inherit another user's key/headers. headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None - resolved.append((sid, model, session_endpoint_url, headers)) + resolved.append((sid, selected_model, session_endpoint_url, headers)) finally: db.close() @@ -203,8 +247,8 @@ def setup_compare_routes(session_manager: SessionManager): comp = Comparison( id=comp_id, prompt=prompt, - model_a=model_a, - model_b=model_b, + model_a=resolved[0][1], + model_b=resolved[1][1], # Record the URL the session actually dials. For URL callers this # is their raw input; for id-only callers (empty endpoint_a/_b) # fall back to the resolved endpoint URL so the column stays @@ -241,7 +285,8 @@ def setup_compare_routes(session_manager: SessionManager): winner: str = Form(...), # "left", "right", or "tie" ): """Record the user's vote and reveal model names if blind.""" - user = get_current_user(request) + require_chat_scope(request) + user = effective_user(request) db = SessionLocal() try: comp = db.query(Comparison).filter(Comparison.id == comp_id).first() @@ -283,15 +328,20 @@ def setup_compare_routes(session_manager: SessionManager): @router.post("/record") def record_comparison(request: Request, body: RecordVoteRequest): """Lightweight endpoint to record a comparison vote from the frontend.""" - user = get_current_user(request) + require_chat_scope(request) + user = effective_user(request) comp_id = str(uuid.uuid4()) - model_a = body.models[0] if len(body.models) > 0 else "" - model_b = body.models[1] if len(body.models) > 1 else "" + models = list(body.models or []) + if is_bearer_principal(request): + models = _validate_bearer_compare_models(models, user) + + model_a = models[0] if len(models) > 0 else "" + model_b = models[1] if len(models) > 1 else "" # For N>2 models, store the full list as JSON in blind_mapping - if len(body.models) > 2: - blind_mapping = json.dumps({"models": body.models}) + if len(models) > 2: + blind_mapping = json.dumps({"models": models}) else: blind_mapping = None @@ -320,7 +370,8 @@ def setup_compare_routes(session_manager: SessionManager): @router.get("/history") def list_comparisons(request: Request): """List past comparisons.""" - user = get_current_user(request) + require_chat_scope(request) + user = effective_user(request) db = SessionLocal() try: q = db.query(Comparison) @@ -346,7 +397,8 @@ def setup_compare_routes(session_manager: SessionManager): @router.delete("/{comp_id}") def delete_comparison(request: Request, comp_id: str): """Delete a comparison and its ephemeral sessions.""" - user = get_current_user(request) + require_chat_scope(request) + user = effective_user(request) db = SessionLocal() try: comp = db.query(Comparison).filter(Comparison.id == comp_id).first() diff --git a/routes/gallery/gallery_routes.py b/routes/gallery/gallery_routes.py index e6b5e0713..dc10bc155 100644 --- a/routes/gallery/gallery_routes.py +++ b/routes/gallery/gallery_routes.py @@ -10,11 +10,19 @@ import uuid from pathlib import Path from typing import Dict, Any, Optional -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint from core.database import Session as DbSession -from src.auth_helpers import get_current_user, owner_filter, require_privilege +from src.auth_helpers import ( + effective_user, + get_current_user, + is_bearer_principal, + owner_filter, + require_chat_scope, + require_non_bearer_request, + require_privilege, +) from src.upload_limits import ( read_upload_limited, GALLERY_UPLOAD_MAX_BYTES, @@ -33,6 +41,13 @@ _SAM_STATE: Dict[str, Any] = {} _GROUNDING_STATE: Dict[str, Any] = {} +def _gallery_owner(request: Request) -> Optional[str]: + """Use the token owner for bearer calls and preserve the legacy seam otherwise.""" + if is_bearer_principal(request): + return effective_user(request) + return get_current_user(request) + + def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"): if not image_b64: raise HTTPException(400, "Missing image") @@ -346,7 +361,10 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]: def setup_gallery_routes() -> APIRouter: - router = APIRouter(tags=["gallery"]) + router = APIRouter( + tags=["gallery"], + dependencies=[Depends(require_chat_scope)], + ) # ---- POST /api/gallery/upload ---- @router.post("/api/gallery/upload") @@ -360,7 +378,7 @@ def setup_gallery_routes() -> APIRouter: if not file or not hasattr(file, 'filename'): raise HTTPException(400, "No file provided") - user = get_current_user(request) + user = _gallery_owner(request) album_id = form.get("album_id") or None content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery upload") @@ -434,7 +452,7 @@ def setup_gallery_routes() -> APIRouter: @router.post("/api/gallery/{image_id}/replace") async def gallery_replace(request: Request, image_id: str): """Replace an existing gallery image file with a new one.""" - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first() @@ -479,7 +497,7 @@ def setup_gallery_routes() -> APIRouter: """Rename a gallery photo. Stores the new name in the `prompt` column (which serves as the user-facing label for uploaded photos that have no AI prompt).""" - user = get_current_user(request) + user = _gallery_owner(request) data = await request.json() new_name = (data.get("name") or "").strip() if not new_name: @@ -516,7 +534,7 @@ def setup_gallery_routes() -> APIRouter: if angle not in (90, -90, 180, 270): raise HTTPException(400, "Angle must be 90, -90, 180, or 270") - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first() @@ -557,7 +575,10 @@ def setup_gallery_routes() -> APIRouter: db.close() # ---- POST /api/gallery/ai-upscale ---- - @router.post("/api/gallery/ai-upscale") + @router.post( + "/api/gallery/ai-upscale", + dependencies=[Depends(require_non_bearer_request)], + ) async def gallery_ai_upscale(request: Request): """AI upscale using img2img with the diffusion server.""" import base64, httpx @@ -601,7 +622,10 @@ def setup_gallery_routes() -> APIRouter: return {"error": "Upscale request failed"} # ---- POST /api/gallery/style-transfer ---- - @router.post("/api/gallery/style-transfer") + @router.post( + "/api/gallery/style-transfer", + dependencies=[Depends(require_non_bearer_request)], + ) async def gallery_style_transfer(request: Request): """Style transfer using img2img with the diffusion server.""" import base64, httpx @@ -651,7 +675,7 @@ def setup_gallery_routes() -> APIRouter: @router.get("/api/gallery/tags") async def gallery_tags(request: Request) -> Dict[str, Any]: """Return distinct tags across all active gallery images.""" - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: q = db.query(GalleryImage.tags).filter( @@ -683,7 +707,7 @@ def setup_gallery_routes() -> APIRouter: offset: int = Query(0, ge=0), limit: int = Query(24, ge=1, le=100), ) -> Dict[str, Any]: - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: # Distinct tags for filter UI @@ -811,7 +835,7 @@ def setup_gallery_routes() -> APIRouter: @router.get("/api/gallery/albums") async def list_albums(request: Request): - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: q = db.query(GalleryAlbum) @@ -850,7 +874,7 @@ def setup_gallery_routes() -> APIRouter: @router.post("/api/gallery/albums") async def create_album(request: Request): import uuid - user = get_current_user(request) + user = _gallery_owner(request) data = await request.json() name = (data.get("name") or "").strip() if not name: @@ -870,7 +894,7 @@ def setup_gallery_routes() -> APIRouter: @router.get("/api/gallery/stats") async def gallery_stats(request: Request): - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: from sqlalchemy import func @@ -894,13 +918,16 @@ def setup_gallery_routes() -> APIRouter: finally: db.close() - @router.post("/api/gallery/ai-tag-batch") + @router.post( + "/api/gallery/ai-tag-batch", + dependencies=[Depends(require_non_bearer_request)], + ) async def ai_tag_batch( request: Request, album_id: Optional[str] = Query(None), limit: int = Query(200), ): - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: q = db.query(GalleryImage).filter( @@ -919,7 +946,7 @@ def setup_gallery_routes() -> APIRouter: # ---- GET /api/gallery/{image_id} ---- @router.get("/api/gallery/{image_id}") async def get_gallery_image(request: Request, image_id: str) -> Dict[str, Any]: - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: row = ( @@ -940,7 +967,7 @@ def setup_gallery_routes() -> APIRouter: # ---- PATCH /api/gallery/{image_id} ---- @router.patch("/api/gallery/{image_id}") async def patch_gallery_image(request: Request, image_id: str, req: GalleryPatch) -> Dict[str, Any]: - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first() @@ -992,7 +1019,7 @@ def setup_gallery_routes() -> APIRouter: # of a flood of individual downloads). @router.post("/api/gallery/download-zip") async def gallery_download_zip(request: Request): - user = get_current_user(request) + user = _gallery_owner(request) if not user: raise HTTPException(401, "Not authenticated") try: @@ -1047,7 +1074,7 @@ def setup_gallery_routes() -> APIRouter: # AI-suggested values you never added. @router.post("/api/gallery/clear-user-tags") async def clear_gallery_user_tags(request: Request) -> Dict[str, Any]: - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: q = db.query(GalleryImage).filter(GalleryImage.is_active == True) @@ -1072,7 +1099,7 @@ def setup_gallery_routes() -> APIRouter: # "woman" have leaked into the gallery and you want them gone. @router.post("/api/gallery/clear-ai-tags") async def clear_gallery_ai_tags(request: Request, image_id: Optional[str] = Query(None)) -> Dict[str, Any]: - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: q = db.query(GalleryImage).filter(GalleryImage.is_active == True) @@ -1099,7 +1126,7 @@ def setup_gallery_routes() -> APIRouter: # Returns how many rows were touched + how many tags removed. @router.post("/api/gallery/dedupe-tags") async def dedupe_gallery_tags(request: Request) -> Dict[str, Any]: - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: q = db.query(GalleryImage).filter(GalleryImage.is_active == True) @@ -1135,7 +1162,7 @@ def setup_gallery_routes() -> APIRouter: # ---- DELETE /api/gallery/{image_id} ---- @router.delete("/api/gallery/{image_id}") async def delete_gallery_image(request: Request, image_id: str) -> Dict[str, str]: - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first() @@ -1254,7 +1281,10 @@ def setup_gallery_routes() -> APIRouter: db.close() # ---- POST /api/image/inpaint — proxy to diffusion server OR OpenAI ---- - @router.post("/api/image/inpaint") + @router.post( + "/api/image/inpaint", + dependencies=[Depends(require_non_bearer_request)], + ) async def inpaint_proxy(request: Request): """Forward inpaint request. If the selected endpoint is OpenAI, re-shape the request for /v1/images/edits (multipart, inverted mask). Otherwise @@ -1512,7 +1542,10 @@ def setup_gallery_routes() -> APIRouter: # scratch using the prompt", ignoring the source. Real img2img sends # the image alongside a `strength` (denoising strength) and the model # mixes that fraction of new noise into the existing pixels. - @router.post("/api/image/harmonize") + @router.post( + "/api/image/harmonize", + dependencies=[Depends(require_non_bearer_request)], + ) async def harmonize_image(request: Request): """Harmonize = img2img. The model preserves (1 - strength) of the original and regenerates `strength` fraction. With strength ~0.4 @@ -1712,7 +1745,10 @@ def setup_gallery_routes() -> APIRouter: "/v1/images/harmonize, /v1/images/img2img, /v1/images/variations, /sdapi/v1/img2img.") # ---- POST /api/image/sharpen ---- - @router.post("/api/image/sharpen") + @router.post( + "/api/image/sharpen", + dependencies=[Depends(require_non_bearer_request)], + ) async def sharpen_image(request: Request): """Apply unsharp-mask sharpening to an image.""" require_privilege(request, "can_generate_images") @@ -1737,7 +1773,10 @@ def setup_gallery_routes() -> APIRouter: # AI denoise via Real-ESRGAN with the realesr-general-x4v3 weights at # outscale=1 + denoise_strength. Falls back to a "package missing" # error so the client can prompt the user to install via Cookbook. - @router.post("/api/image/denoise") + @router.post( + "/api/image/denoise", + dependencies=[Depends(require_non_bearer_request)], + ) async def denoise_image(request: Request): require_privilege(request, "can_generate_images") body = await request.json() @@ -1788,7 +1827,10 @@ def setup_gallery_routes() -> APIRouter: # ---- POST /api/image/upscale-local ---- # Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion # server required. Used by the editor's AI Upscale button. - @router.post("/api/image/upscale-local") + @router.post( + "/api/image/upscale-local", + dependencies=[Depends(require_non_bearer_request)], + ) async def upscale_image_local(request: Request): require_privilege(request, "can_generate_images") body = await request.json() @@ -1834,7 +1876,10 @@ def setup_gallery_routes() -> APIRouter: return {"error": "AI upscale failed"} # ---- POST /api/image/remove-bg ---- - @router.post("/api/image/mask") + @router.post( + "/api/image/mask", + dependencies=[Depends(require_non_bearer_request)], + ) async def smart_mask(request: Request): """Create a neutral segmentation mask from user-provided points or a box. @@ -1960,7 +2005,10 @@ def setup_gallery_routes() -> APIRouter: logger.exception("smart_mask failed") raise HTTPException(500, f"SAM mask failed: {exc}") from exc - @router.post("/api/image/remove-bg") + @router.post( + "/api/image/remove-bg", + dependencies=[Depends(require_non_bearer_request)], + ) async def remove_background(request: Request): """Remove background from an image. If the client passes a `hint_mask` (white-where-the-user-wants-the-subject PNG, same dims as the @@ -2053,7 +2101,10 @@ def setup_gallery_routes() -> APIRouter: return {"image": base64.b64encode(buf.getvalue()).decode()} # ---- POST /api/image/enhance-face ---- - @router.post("/api/image/enhance-face") + @router.post( + "/api/image/enhance-face", + dependencies=[Depends(require_non_bearer_request)], + ) async def enhance_face(request: Request): """Face/portrait enhancement. Uses GFPGAN if available, falls back to PIL.""" require_privilege(request, "can_generate_images") @@ -2139,7 +2190,7 @@ def setup_gallery_routes() -> APIRouter: @router.put("/api/gallery/albums/{album_id}") async def update_album(request: Request, album_id: str): - user = get_current_user(request) + user = _gallery_owner(request) data = await request.json() db = SessionLocal() try: @@ -2160,7 +2211,7 @@ def setup_gallery_routes() -> APIRouter: @router.delete("/api/gallery/albums/{album_id}") async def delete_album(request: Request, album_id: str): - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: album = _get_or_404_album(db, album_id, user) @@ -2176,7 +2227,7 @@ def setup_gallery_routes() -> APIRouter: @router.post("/api/gallery/albums/{album_id}/add") async def add_to_album(request: Request, album_id: str): - user = get_current_user(request) + user = _gallery_owner(request) data = await request.json() ids = data.get("image_ids", []) db = SessionLocal() @@ -2194,7 +2245,7 @@ def setup_gallery_routes() -> APIRouter: @router.post("/api/gallery/albums/{album_id}/remove") async def remove_from_album(request: Request, album_id: str): - user = get_current_user(request) + user = _gallery_owner(request) data = await request.json() ids = data.get("image_ids", []) db = SessionLocal() @@ -2215,7 +2266,7 @@ def setup_gallery_routes() -> APIRouter: @router.post("/api/gallery/{image_id}/favorite") async def toggle_favorite(request: Request, image_id: str): - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: img = _get_or_404_image(db, image_id, user) @@ -2227,13 +2278,16 @@ def setup_gallery_routes() -> APIRouter: # ---- AI auto-tag ---- - @router.post("/api/gallery/{image_id}/ai-tag") + @router.post( + "/api/gallery/{image_id}/ai-tag", + dependencies=[Depends(require_non_bearer_request)], + ) async def ai_tag_image(request: Request, image_id: str): """Send image to vision model for auto-tagging.""" import base64, httpx from pathlib import Path - user = get_current_user(request) + user = _gallery_owner(request) db = SessionLocal() try: img = _get_or_404_image(db, image_id, user) diff --git a/routes/model_routes.py b/routes/model_routes.py index 0654ec71c..6adc8c747 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -1372,6 +1372,68 @@ def _picker_models_for_endpoint(ep, base_url: str, kind: str): ), pinned +def _validate_bearer_model_selection( + ep, + requested_model: Optional[str], + *, + allow_empty: bool = False, +) -> str: + """Validate a bearer-selected model against the server-owned picker. + + Bearer requests cannot perform a provider model probe. Their model choice + must therefore come from the same endpoint-local cache/pin inventory that + the server exposes to the model picker. ``allow_empty`` is used only by + default-chat, where an explicitly empty inventory has a deterministic empty + result rather than an implicit provider alias. + """ + if ep is None: + if allow_empty: + return "" + raise HTTPException(400, "A registered model endpoint is required") + + base_url = _normalize_base(getattr(ep, "base_url", "") or "") + kind = _effective_endpoint_kind(ep, base_url) + models, _ = _picker_models_for_endpoint(ep, base_url, kind) + models = [model for model in models if isinstance(model, str) and model.strip()] + requested = str(requested_model or "").strip() + if not requested: + if models: + return models[0] + if allow_empty: + return "" + raise HTTPException(400, "No permitted model is configured for this endpoint") + # A registered local endpoint may intentionally have no persisted + # catalog: local models are operator-controlled and bearer requests + # must not discover them live. Preserve that documented compatibility + # path, while still enforcing any inventory that the server does own + # and rejecting explicitly hidden entries below. + raw_inventory = _merge_model_ids( + _normalize_model_ids(getattr(ep, "cached_models", None)), + _normalize_model_ids(getattr(ep, "pinned_models", None)), + ) + hidden = set(_normalize_model_ids(getattr(ep, "hidden_models", None))) + if ( + requested + and not raw_inventory + and _classify_endpoint(base_url, kind) == "local" + and requested not in hidden + ): + return requested + if requested in models: + return requested + + requested_base = os.path.basename(requested.rstrip("/")) + matches = [ + model for model in models + if os.path.basename(model.rstrip("/")) == requested_base + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise HTTPException(400, "Model selection is ambiguous for this endpoint") + raise HTTPException(400, f"Model is not permitted for this endpoint: {requested}") + + def _api_key_fingerprint(api_key: Optional[str]) -> str: """Stable, non-secret label for distinguishing same-URL credentials.""" key = (api_key or "").strip() @@ -2501,7 +2563,13 @@ def setup_model_routes(model_discovery): return {"endpoint_id": "", "endpoint_url": "", "model": ""} base = _normalize_base(ep.base_url) chat_url = build_chat_url(base) - if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)): + if is_bearer_principal(request): + model = _validate_bearer_model_selection( + ep, + model, + allow_empty=True, + ) + elif not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)): try: visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None)) if visible: diff --git a/routes/session_routes.py b/routes/session_routes.py index 896fc6cf4..bd84c182c 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -370,6 +370,7 @@ def setup_session_routes( user = effective_user(request) endpoint_api_key = "" endpoint_base_url = "" + endpoint_row = None _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url) if endpoint_id and endpoint_id.strip(): from core.database import ModelEndpoint @@ -403,7 +404,14 @@ def setup_session_routes( from src.endpoint_resolver import build_headers validation_headers = build_headers(effective_api_key, endpoint_base_url or endpoint_url) - if skip_val: + if is_bearer_principal(request) and endpoint_row is not None: + # Bearer requests are cache-only, but cache-only does not mean + # caller-authorized. Validate explicit selections and choose an + # empty selection deterministically from the server-owned picker. + from routes.model_routes import _validate_bearer_model_selection + + model_to_use = _validate_bearer_model_selection(endpoint_row, model_to_use) + elif skip_val: # skip_validation = trust the caller and do NOT probe /v1/models. # Used for custom endpoints AND for bare placeholder sessions with no # model at all (e.g. an email reply draft just needs a session to live diff --git a/routes/webhook/webhook_routes.py b/routes/webhook/webhook_routes.py index 30d55e70c..8a6876435 100644 --- a/routes/webhook/webhook_routes.py +++ b/routes/webhook/webhook_routes.py @@ -124,6 +124,33 @@ def _cached_endpoint_model_ids(endpoint) -> list[str]: return ids +def _validate_bearer_sync_model(endpoint, requested_model: str) -> str: + """Validate a configured sync model without probing its provider.""" + try: + from routes.model_routes import _validate_bearer_model_selection + + return _validate_bearer_model_selection(endpoint, requested_model) + except ImportError: + # Keep the lightweight webhook test/import seam usable when optional + # route modules are deliberately stubbed. Production uses the + # central picker validator above; this fallback remains cache-only. + models = _cached_endpoint_model_ids(endpoint) + requested = str(requested_model or "").strip() + if requested and requested in models: + return requested + if not requested and models: + return models[0] + if ( + requested + and not models + and not getattr(endpoint, "cached_models", None) + and not getattr(endpoint, "pinned_models", None) + and "localhost" in str(getattr(endpoint, "base_url", "")).lower() + ): + return requested + raise HTTPException(400, "Model is not permitted for this endpoint") + + def setup_webhook_routes( webhook_manager: WebhookManager, auth_manager, @@ -389,23 +416,27 @@ def setup_webhook_routes( base_url = normalize_base(ep.base_url) endpoint_url = build_chat_url(base_url) - model = body.model or "auto" + model = body.model or "" api_key = ep.api_key if getattr(ep, "provider_auth_id", None): try: from src.endpoint_resolver import resolve_endpoint_runtime - base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner) + runtime_kwargs = {} + if not capability.allow_live_probes: + runtime_kwargs["allow_live_probes"] = False + base_url, api_key = resolve_endpoint_runtime( + ep, + owner=token_owner, + **runtime_kwargs, + ) endpoint_url = build_chat_url(base_url) except Exception: raise HTTPException(500, "Could not resolve endpoint credentials") - if model == "auto": - # 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" + # This route is bearer-only. Explicit and empty selections both + # use the same server-owned, cache-only picker inventory; an empty + # inventory is an error rather than an implicit provider alias. + model = _validate_bearer_sync_model(ep, model) if not session_manager: raise HTTPException(500, "Session manager not available") diff --git a/src/chatgpt_subscription.py b/src/chatgpt_subscription.py index e65ccbc8d..39c430b56 100644 --- a/src/chatgpt_subscription.py +++ b/src/chatgpt_subscription.py @@ -251,7 +251,17 @@ def access_token_is_expiring(access_token: str, skew_seconds: int = CHATGPT_ACCE return exp <= int(time.time()) + int(skew_seconds) -def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, force_refresh: bool = False) -> Dict[str, Any]: +def resolve_runtime_credentials( + auth_id: str, + owner: Optional[str] = None, + *, + force_refresh: bool = False, + allow_live_probes: bool = True, +) -> 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() db = SessionLocal() try: diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py index 9e8a7e10a..b4aa2ddd6 100644 --- a/src/endpoint_resolver.py +++ b/src/endpoint_resolver.py @@ -143,7 +143,12 @@ def _endpoint_enabled_models(ep) -> list: return [m for m in merged if m not in hidden] -def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]: +def resolve_endpoint_runtime( + ep, + owner: Optional[str] = None, + *, + allow_live_probes: bool = True, +) -> Tuple[str, Optional[str]]: """Resolve a ModelEndpoint row to its runtime base URL and bearer/API key. Static-key providers use ``ModelEndpoint.api_key``. Session-backed providers @@ -153,7 +158,7 @@ def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Opti base = normalize_base(getattr(ep, "base_url", "") or "") api_key = getattr(ep, "api_key", None) auth_id = getattr(ep, "provider_auth_id", None) - if auth_id: + if auth_id and allow_live_probes: from src.chatgpt_subscription import resolve_runtime_credentials creds = resolve_runtime_credentials(auth_id, owner=owner) @@ -346,6 +351,8 @@ def resolve_endpoint( fallback_model: Optional[str] = None, fallback_headers: Optional[Dict] = None, owner: Optional[str] = None, + *, + allow_live_probes: bool = True, ) -> Tuple[Optional[str], Optional[str], Optional[Dict]]: """Resolve an endpoint/model from settings, with fallback. @@ -407,7 +414,14 @@ def resolve_endpoint( return fallback_url, fallback_model, fallback_headers try: - base, api_key = resolve_endpoint_runtime(ep, owner=owner) + runtime_kwargs = {} + if not allow_live_probes: + runtime_kwargs["allow_live_probes"] = False + base, api_key = resolve_endpoint_runtime( + ep, + owner=owner, + **runtime_kwargs, + ) except Exception as e: logger.warning("Could not resolve endpoint runtime credentials: %s", e) return fallback_url, fallback_model, fallback_headers @@ -440,6 +454,7 @@ def _resolve_endpoint_by_id_with_descriptor( owner: Optional[str] = None, *, require_exact_model: bool = False, + allow_live_probes: bool = True, ) -> Optional[Tuple[Tuple[str, str, Dict], dict]]: """Resolve a concrete endpoint/model plus its non-secret descriptor. @@ -461,7 +476,14 @@ def _resolve_endpoint_by_id_with_descriptor( if not ep: return None try: - base, api_key = resolve_endpoint_runtime(ep, owner=owner) + runtime_kwargs = {} + if not allow_live_probes: + runtime_kwargs["allow_live_probes"] = False + base, api_key = resolve_endpoint_runtime( + ep, + owner=owner, + **runtime_kwargs, + ) except Exception as e: logger.warning("Could not resolve endpoint runtime credentials: %s", e) return None @@ -509,15 +531,17 @@ def resolve_endpoint_by_id( owner: Optional[str] = None, *, require_exact_model: bool = False, + allow_live_probes: bool = True, ) -> Optional[Tuple[str, str, Dict]]: """Resolve a specific endpoint id (+ optional model) to its runtime route.""" - resolved = _resolve_endpoint_by_id_with_descriptor( - ep_id, - model, - owner=owner, - require_exact_model=require_exact_model, - ) + descriptor_kwargs = { + "owner": owner, + "require_exact_model": require_exact_model, + } + if not allow_live_probes: + descriptor_kwargs["allow_live_probes"] = False + resolved = _resolve_endpoint_by_id_with_descriptor(ep_id, model, **descriptor_kwargs) return resolved[0] if resolved else None @@ -526,6 +550,8 @@ def resolve_route_descriptor( model: str, headers: Optional[Dict] = None, owner: Optional[str] = None, + *, + allow_live_probes: bool = True, ) -> dict: """Return the visible endpoint identity for an already-resolved route. @@ -548,11 +574,16 @@ def resolve_route_descriptor( q = owner_filter(q, ModelEndpoint, owner) expected = (endpoint_url.rstrip("/"), model, headers or {}) for ep in q.all(): + descriptor_kwargs = { + "owner": owner, + "require_exact_model": True, + } + if not allow_live_probes: + descriptor_kwargs["allow_live_probes"] = False resolved = _resolve_endpoint_by_id_with_descriptor( ep.id, model, - owner=owner, - require_exact_model=True, + **descriptor_kwargs, ) if not resolved: continue @@ -577,6 +608,8 @@ def resolve_route_descriptor_by_id( model: str, headers: Optional[Dict] = None, owner: Optional[str] = None, + *, + allow_live_probes: bool = True, ) -> Optional[dict]: """Resolve a selected route's identity without relying on row order. @@ -586,11 +619,16 @@ def resolve_route_descriptor_by_id( identical. """ + descriptor_kwargs = { + "owner": owner, + "require_exact_model": True, + } + if not allow_live_probes: + descriptor_kwargs["allow_live_probes"] = False resolved = _resolve_endpoint_by_id_with_descriptor( endpoint_id, model, - owner=owner, - require_exact_model=True, + **descriptor_kwargs, ) if not resolved: return None @@ -600,24 +638,46 @@ def resolve_route_descriptor_by_id( return descriptor if actual == expected else None -def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list: +def resolve_utility_fallback_candidates( + owner: Optional[str] = None, + *, + allow_live_probes: bool = True, +) -> list: """Configured fallback chain for the Utility model (`utility_model_fallbacks`).""" - return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner) + fallback_kwargs = {"owner": owner} + if not allow_live_probes: + fallback_kwargs["allow_live_probes"] = False + return _resolve_fallback_candidates("utility_model_fallbacks", **fallback_kwargs) -def resolve_vision_fallback_candidates(owner: Optional[str] = None) -> list: +def resolve_vision_fallback_candidates( + owner: Optional[str] = None, + *, + allow_live_probes: bool = True, +) -> list: """Configured fallback chain for the Vision model (`vision_model_fallbacks`).""" - return _resolve_fallback_candidates("vision_model_fallbacks", owner=owner) + fallback_kwargs = {"owner": owner} + if not allow_live_probes: + fallback_kwargs["allow_live_probes"] = False + return _resolve_fallback_candidates("vision_model_fallbacks", **fallback_kwargs) -def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list: +def _resolve_fallback_candidates( + setting_key: str, + owner: Optional[str] = None, + *, + allow_live_probes: bool = True, +) -> list: try: from src.settings import get_user_setting, load_settings settings = load_settings() chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or [] except Exception: return [] - return resolve_fallback_entries(chain, owner=owner) + resolver_kwargs = {"owner": owner} + if not allow_live_probes: + resolver_kwargs["allow_live_probes"] = False + return resolve_fallback_entries(chain, **resolver_kwargs) def resolve_fallback_entries( @@ -625,6 +685,7 @@ def resolve_fallback_entries( owner: Optional[str] = None, *, require_exact_model: bool = False, + allow_live_probes: bool = True, ) -> list: """Resolve ordered endpoint/model entries within the caller's owner scope.""" @@ -632,11 +693,16 @@ def resolve_fallback_entries( for entry in entries or []: if not isinstance(entry, dict): continue + resolver_kwargs = { + "owner": owner, + "require_exact_model": require_exact_model, + } + if not allow_live_probes: + resolver_kwargs["allow_live_probes"] = False resolved = resolve_endpoint_by_id( entry.get("endpoint_id", ""), entry.get("model", ""), - owner=owner, - require_exact_model=require_exact_model, + **resolver_kwargs, ) if resolved and resolved not in out: out.append(resolved) @@ -648,6 +714,7 @@ def resolve_fallback_entries_with_descriptors( owner: Optional[str] = None, *, require_exact_model: bool = False, + allow_live_probes: bool = True, ) -> list: """Resolve ordered entries while retaining safe endpoint provenance.""" @@ -656,11 +723,16 @@ def resolve_fallback_entries_with_descriptors( for entry in entries or []: if not isinstance(entry, dict): continue + descriptor_kwargs = { + "owner": owner, + "require_exact_model": require_exact_model, + } + if not allow_live_probes: + descriptor_kwargs["allow_live_probes"] = False resolved = _resolve_endpoint_by_id_with_descriptor( entry.get("endpoint_id", ""), entry.get("model", ""), - owner=owner, - require_exact_model=require_exact_model, + **descriptor_kwargs, ) if not resolved: continue diff --git a/src/foreground_model_routing.py b/src/foreground_model_routing.py index 76e254614..621809d67 100644 --- a/src/foreground_model_routing.py +++ b/src/foreground_model_routing.py @@ -59,6 +59,8 @@ def _load_policy_preferences(owner: Optional[str]) -> dict: def resolve_foreground_model_policy( owner: Optional[str] = None, allowed_models: Optional[Collection[str]] = None, + *, + allow_live_probes: bool = True, ) -> ForegroundModelPolicy: """Resolve an explicit owner-scoped policy, failing closed to strict mode. @@ -95,11 +97,13 @@ def resolve_foreground_model_policy( if resolve_fallback_entries is not _DEFAULT_FALLBACK_ENTRY_RESOLVER: # Preserve the long-standing resolver seam used by downstream tests and # integrations. Production uses the descriptor-aware resolver below. - compatibility_candidates = resolve_fallback_entries( - entries, - owner=owner, - require_exact_model=True, - ) + resolver_kwargs = { + "owner": owner, + "require_exact_model": True, + } + if not allow_live_probes: + resolver_kwargs["allow_live_probes"] = False + compatibility_candidates = resolve_fallback_entries(entries, **resolver_kwargs) # Known limitation of this test-only seam: alignment matches on model # alone, so when two entries share a model and the resolver skips the # first, the surviving candidate inherits the skipped entry's @@ -128,11 +132,13 @@ def resolve_foreground_model_policy( } resolved_routes.append((candidate, descriptor)) else: - resolved_routes = resolve_fallback_entries_with_descriptors( - entries, - owner=owner, - require_exact_model=True, - ) + resolver_kwargs = { + "owner": owner, + "require_exact_model": True, + } + if not allow_live_probes: + resolver_kwargs["allow_live_probes"] = False + resolved_routes = resolve_fallback_entries_with_descriptors(entries, **resolver_kwargs) candidates = [candidate for candidate, _descriptor in resolved_routes] if not candidates: return ForegroundModelPolicy() @@ -146,10 +152,19 @@ def resolve_foreground_model_policy( ) -def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list: +def resolve_foreground_fallback_candidates( + owner: Optional[str] = None, + *, + allow_live_probes: bool = True, +) -> list: """Return only candidates explicitly enabled by the current user.""" - return list(resolve_foreground_model_policy(owner).fallback_candidates) + return list( + resolve_foreground_model_policy( + owner, + allow_live_probes=allow_live_probes, + ).fallback_candidates + ) def build_foreground_model_candidates( @@ -158,10 +173,16 @@ def build_foreground_model_candidates( headers: Optional[Dict[str, Any]] = None, owner: Optional[str] = None, policy: Optional[ForegroundModelPolicy] = None, + *, + allow_live_probes: bool = True, ) -> list: """Build the ordered candidate list for a foreground request.""" - policy = policy or resolve_foreground_model_policy(owner) + if policy is None: + policy_kwargs = {} + if not allow_live_probes: + policy_kwargs["allow_live_probes"] = False + policy = resolve_foreground_model_policy(owner, **policy_kwargs) primary = (endpoint_url, model, headers or {}) candidates = [primary] for candidate in policy.fallback_candidates: @@ -177,21 +198,38 @@ def build_foreground_route_descriptors( owner: Optional[str] = None, policy: Optional[ForegroundModelPolicy] = None, selected_endpoint_id: Optional[str] = None, + *, + allow_live_probes: bool = True, ) -> list: """Build safe route metadata parallel to foreground candidates.""" - policy = policy or resolve_foreground_model_policy(owner) + if policy is None: + policy_kwargs = {} + if not allow_live_probes: + policy_kwargs["allow_live_probes"] = False + policy = resolve_foreground_model_policy(owner, **policy_kwargs) selected = None if selected_endpoint_id: + descriptor_kwargs = {"owner": owner} + if not allow_live_probes: + descriptor_kwargs["allow_live_probes"] = False selected = resolve_route_descriptor_by_id( selected_endpoint_id, endpoint_url, model, headers or {}, - owner=owner, + **descriptor_kwargs, ) if selected is None: - selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner) + descriptor_kwargs = {"owner": owner} + if not allow_live_probes: + descriptor_kwargs["allow_live_probes"] = False + selected = resolve_route_descriptor( + endpoint_url, + model, + headers or {}, + **descriptor_kwargs, + ) primary = (endpoint_url, model, headers or {}) candidates = [primary] descriptors = [selected] diff --git a/tests/test_api_token_cycle6_repair.py b/tests/test_api_token_cycle6_repair.py new file mode 100644 index 000000000..861718372 --- /dev/null +++ b/tests/test_api_token_cycle6_repair.py @@ -0,0 +1,658 @@ +"""Regression coverage for the cycle-6 API-token repair boundaries.""" + +import json +from types import SimpleNamespace + +import httpx +import pytest +from fastapi import APIRouter, FastAPI +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb + + +class _Request: + def __init__(self, *, scopes=("chat",), bearer=True, owner="alice"): + self.state = SimpleNamespace( + api_token=bearer, + api_token_owner=owner if bearer else None, + api_token_scopes=list(scopes), + current_user="api" if bearer else owner, + ) + self.app = SimpleNamespace(state=SimpleNamespace(auth_manager=None)) + self.headers = {"authorization": "Bearer ody_test"} if bearer else {} + self.client = SimpleNamespace(host="127.0.0.1") + + +class _EndpointDb: + def __init__(self, endpoint): + self.endpoint = endpoint + + def query(self, *args, **kwargs): + return self + + def filter(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + return self + + def first(self): + return self.endpoint + + def all(self): + return [self.endpoint] if self.endpoint is not None else [] + + def close(self): + return None + + +class _StateInjector: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + headers = dict(scope.get("headers") or []) + if headers.get(b"x-api-token") == b"1": + scope["state"] = { + "api_token": True, + "api_token_owner": headers.get(b"x-api-owner", b"").decode() or None, + "api_token_scopes": [ + value for value in headers.get(b"x-api-scopes", b"").decode().split(",") + if value + ], + "current_user": "api", + } + else: + scope["state"] = { + "api_token": False, + "current_user": headers.get(b"x-user", b"").decode() or None, + } + await self.app(scope, receive, send) + + +def _client(app): + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://cycle6.test", + ) + + +def _endpoint(router, path, method): + for route in reversed(router.routes): + if route.path == path and method in route.methods: + return route.endpoint + raise AssertionError(f"route not found: {method} {path}") + + +def _isolated_db(tmp_path): + engine = create_engine( + f"sqlite:///{tmp_path / 'cycle6-repair.db'}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, + ) + cdb.Base.metadata.create_all(engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +@pytest.mark.asyncio +async def test_gallery_asgi_scope_and_non_bearer_boundaries(monkeypatch, tmp_path): + from routes.gallery import gallery_routes + from core.database import GalleryImage + + db_session = _isolated_db(tmp_path) + image_dir = tmp_path / "generated-images" + monkeypatch.setattr(gallery_routes, "SessionLocal", db_session) + monkeypatch.setattr(gallery_routes, "GENERATED_IMAGES_DIR", image_dir) + monkeypatch.setattr(gallery_routes, "GALLERY_IMAGE_DIR", image_dir) + + app = FastAPI() + app.include_router(gallery_routes.setup_gallery_routes()) + client = _client(_StateInjector(app)) + + async with client: + response = await client.post( + "/api/gallery/upload", + files={"file": ("photo.png", b"not-a-real-image", "image/png")}, + headers={ + "x-api-token": "1", + "x-api-owner": "alice", + "x-api-scopes": "todos:read", + "authorization": "Bearer ody_test", + }, + ) + assert response.status_code == 403 + + response = await client.post( + "/api/gallery/upload", + files={"file": ("photo.png", b"not-a-real-image", "image/png")}, + headers={ + "x-api-token": "1", + "x-api-owner": "alice", + "x-api-scopes": "chat", + "authorization": "Bearer ody_test", + }, + ) + assert response.status_code == 200, response.text + + for path, method in ( + ("/api/gallery/ai-tag-batch", "post"), + ("/api/gallery/unknown/ai-tag", "post"), + ("/api/image/inpaint", "post"), + ): + response = await getattr(client, method)( + path, + headers={ + "x-api-token": "1", + "x-api-owner": "alice", + "x-api-scopes": "chat", + "authorization": "Bearer ody_test", + }, + ) + assert response.status_code == 403, (path, response.text) + + db = db_session() + try: + row = db.query(GalleryImage).first() + assert row is not None + assert row.owner == "alice" + finally: + db.close() + + +@pytest.mark.asyncio +async def test_gallery_cookie_ai_tag_uses_fake_provider_and_bearer_never_reaches_it( + monkeypatch, + tmp_path, +): + from routes.gallery import gallery_routes + from core.database import GalleryImage + + db_session = _isolated_db(tmp_path) + image_dir = tmp_path / "gallery" + image_dir.mkdir() + (image_dir / "image.png").write_bytes(b"fake-image") + db = db_session() + try: + db.add(GalleryImage( + id="image-1", + filename="image.png", + prompt="photo", + model="imported", + owner="alice", + file_hash="hash", + file_size=10, + )) + db.commit() + finally: + db.close() + + monkeypatch.setattr(gallery_routes, "SessionLocal", db_session) + monkeypatch.setattr(gallery_routes, "GALLERY_IMAGE_DIR", image_dir) + monkeypatch.setattr( + "src.document_processor._load_vl_settings", + lambda: {"vision_enabled": True, "vision_model": "vision-model"}, + ) + monkeypatch.setattr( + "src.document_processor._resolve_vl_model", + lambda configured, owner=None: ( + "https://vision.example/v1/chat/completions", + configured, + {}, + ), + ) + + provider_calls = [] + + class _Response: + status_code = 200 + text = "" + + def json(self): + return {"choices": [{"message": {"content": "photo, test"}}]} + + class _FakeClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def post(self, *args, **kwargs): + provider_calls.append((args, kwargs)) + return _Response() + + app = FastAPI() + app.include_router(gallery_routes.setup_gallery_routes()) + client = _client(_StateInjector(app)) + monkeypatch.setattr(httpx, "AsyncClient", _FakeClient) + + async with client: + cookie_response = await client.post( + "/api/gallery/image-1/ai-tag", + headers={"x-user": "alice"}, + ) + assert cookie_response.status_code == 200, cookie_response.text + assert provider_calls + before_bearer = len(provider_calls) + + bearer_response = await client.post( + "/api/gallery/image-1/ai-tag", + headers={ + "x-api-token": "1", + "x-api-owner": "alice", + "x-api-scopes": "chat", + "authorization": "Bearer ody_test", + }, + ) + assert bearer_response.status_code == 403 + assert len(provider_calls) == before_bearer + + +def test_bearer_model_selection_rejects_hidden_unlisted_and_empty_inventory(): + from routes.model_routes import _validate_bearer_model_selection + + endpoint = SimpleNamespace( + base_url="https://api.example.test/v1", + endpoint_kind="api", + cached_models=json.dumps(["cached-model", "hidden-model"]), + pinned_models=json.dumps(["allowed-model"]), + hidden_models=json.dumps(["hidden-model"]), + ) + assert _validate_bearer_model_selection(endpoint, "allowed-model") == "allowed-model" + for model in ("cached-model", "hidden-model", "missing-model"): + with pytest.raises(HTTPException) as exc: + _validate_bearer_model_selection(endpoint, model) + assert exc.value.status_code == 400 + + endpoint.pinned_models = "[]" + assert _validate_bearer_model_selection(endpoint, "", allow_empty=True) == "" + with pytest.raises(HTTPException): + _validate_bearer_model_selection(endpoint, "cached-model") + + +def test_bearer_default_chat_empty_pin_does_not_fall_back_to_cache(monkeypatch): + from routes import model_routes + from routes import prefs_routes + + endpoint = SimpleNamespace( + id="ep", + base_url="https://api.example.test/v1", + endpoint_kind="api", + is_enabled=True, + cached_models=json.dumps(["cached-model"]), + pinned_models="[]", + hidden_models=None, + ) + db = _EndpointDb(endpoint) + monkeypatch.setattr(model_routes, "SessionLocal", lambda: db) + monkeypatch.setattr(model_routes, "_load_settings", lambda: { + "default_endpoint_id": "ep", + "default_model": "", + "share_defaults_with_users": False, + }) + monkeypatch.setattr(prefs_routes, "_load_for_user", lambda owner: {}) + route = _endpoint(model_routes.setup_model_routes(None), "/api/default-chat", "GET") + + result = route(_Request()) + assert result == { + "endpoint_id": "ep", + "endpoint_url": "https://api.example.test/v1/chat/completions", + "model": "", + } + + +def test_bearer_session_model_is_checked_against_endpoint_inventory(monkeypatch): + from routes import session_routes + + endpoint = SimpleNamespace( + id="ep", + is_enabled=True, + base_url="https://api.example.test/v1", + api_key=None, + endpoint_kind="api", + cached_models=json.dumps(["provider-model"]), + pinned_models=json.dumps(["allowed-model"]), + hidden_models=None, + ) + db = _EndpointDb(endpoint) + monkeypatch.setattr(session_routes, "SessionLocal", lambda: db) + manager = SimpleNamespace(create_session=lambda **kwargs: pytest.fail("session was created")) + route = _endpoint( + session_routes.setup_session_routes(manager, {}), + "/api/session", + "POST", + ) + + with pytest.raises(HTTPException) as exc: + route( + request=_Request(), + name="chat", + endpoint_url="", + model="provider-model", + rag=None, + skip_validation="true", + api_key="", + endpoint_id="ep", + ) + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_sync_chat_uses_cached_model_and_skips_provider_runtime_resolution(monkeypatch): + from routes import webhook_routes + from src import chatgpt_subscription, llm_core + + endpoint = SimpleNamespace( + owner="alice", + is_enabled=True, + created_at=1, + base_url="https://chatgpt.com/backend-api/codex", + api_key=None, + provider_auth_id="provider-auth", + endpoint_kind="api", + cached_models=json.dumps(["cached-model"]), + pinned_models=json.dumps(["allowed-model"]), + hidden_models=None, + ) + monkeypatch.setattr(webhook_routes, "SessionLocal", lambda: _EndpointDb(endpoint)) + runtime_calls = [] + monkeypatch.setattr( + chatgpt_subscription, + "resolve_runtime_credentials", + lambda *args, **kwargs: runtime_calls.append((args, kwargs)) or pytest.fail( + "bearer sync resolved provider credentials" + ), + ) + llm_calls = [] + + async def fake_llm(*args, **kwargs): + llm_calls.append(kwargs) + return "reply" + + monkeypatch.setattr(llm_core, "llm_call_async", fake_llm) + + class _Session: + def __init__(self, **kwargs): + self.endpoint_url = kwargs["endpoint_url"] + self.model = kwargs["model"] + self.headers = {} + self.history = [] + + def add_message(self, message): + self.history.append(message) + + manager = SimpleNamespace( + create_session=lambda **kwargs: _Session(**kwargs), + save_sessions=lambda: None, + ) + router = webhook_routes.setup_webhook_routes( + SimpleNamespace(fire_and_forget=lambda *args, **kwargs: None), + None, + session_manager=manager, + ) + route = _endpoint(router, "/api/v1/chat", "POST") + body = SimpleNamespace( + message="hello", + model=None, + session=None, + api_key=None, + base_url=None, + provider=None, + ) + + result = await route(request=_Request(), body=body) + assert result["model"] == "allowed-model" + assert runtime_calls == [] + assert llm_calls[0]["allow_live_probes"] is False + + +def test_provider_runtime_guard_is_no_live_without_opening_credentials_db(monkeypatch): + from src import chatgpt_subscription + + monkeypatch.setattr( + chatgpt_subscription, + "_database_handles", + lambda: pytest.fail("no-live provider guard opened the credentials database"), + ) + with pytest.raises(chatgpt_subscription.ChatGPTSubscriptionReauthRequired): + chatgpt_subscription.resolve_runtime_credentials( + "provider-auth", + owner="alice", + allow_live_probes=False, + ) + + +def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolution(monkeypatch): + from src import chatgpt_subscription, endpoint_resolver, foreground_model_routing + + endpoint = SimpleNamespace( + id="ep", + name="Subscription", + is_enabled=True, + base_url="https://chatgpt.com/backend-api/codex", + api_key=None, + provider_auth_id="provider-auth", + endpoint_kind="api", + cached_models=json.dumps(["allowed-model"]), + pinned_models=json.dumps(["allowed-model"]), + hidden_models=None, + ) + monkeypatch.setattr(endpoint_resolver, "SessionLocal", lambda: _EndpointDb(endpoint)) + monkeypatch.setattr( + chatgpt_subscription, + "resolve_runtime_credentials", + lambda *args, **kwargs: pytest.fail("foreground descriptor resolved provider credentials"), + ) + + descriptors = foreground_model_routing.build_foreground_route_descriptors( + "https://chatgpt.com/backend-api/codex/responses", + "allowed-model", + {}, + owner="alice", + policy=foreground_model_routing.ForegroundModelPolicy(), + allow_live_probes=False, + ) + assert descriptors[0]["endpoint_label"] in {"Subscription", "Selected route"} + + +@pytest.mark.asyncio +async def test_codex_owner_bridge_is_asgi_compatible_with_real_bearer_header(): + from routes import codex_routes + from src.auth_helpers import is_bearer_principal, require_user + + memory_router = APIRouter(prefix="/api/memory") + + @memory_router.get("") + async def memory_list(request): + return { + "owner": require_user(request), + "bearer": is_bearer_principal(request), + "authorization": request.headers.get("authorization"), + } + + app = FastAPI() + app.include_router(codex_routes.setup_codex_routes(memory_router=memory_router)) + async with _client(_StateInjector(app)) as client: + response = await client.get( + "/api/codex/memory", + headers={ + "x-api-token": "1", + "x-api-owner": "alice", + "x-api-scopes": "memory:read", + "authorization": "Bearer ody_test", + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "owner": "alice", + "bearer": False, + "authorization": None, + } + + +@pytest.mark.asyncio +async def test_codex_owner_bridge_directly_restores_scope_headers(): + from starlette.requests import Request + + from routes.codex_routes import _as_owner + from src.auth_helpers import is_bearer_principal, require_user + + original_headers = [ + (b"authorization", b"Bearer ody_test"), + (b"x-test", b"1"), + ] + scope = {"type": "http", "headers": original_headers, "state": { + "api_token": True, + "api_token_owner": "alice", + "api_token_scopes": ["memory:read"], + "current_user": "api", + }} + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + request = Request(scope, receive) + assert request.headers.get("authorization") == "Bearer ody_test" + + async def nested(req): + assert not is_bearer_principal(req) + assert req.headers.get("authorization") is None + assert require_user(req) == "alice" + return "ok" + + assert await _as_owner(request, "alice", nested, request) == "ok" + assert scope["headers"] == original_headers + assert request.headers.get("authorization") == "Bearer ody_test" + assert is_bearer_principal(request) + assert request.state.api_token is True + assert request.state.current_user == "api" + + +def test_compare_direct_model_gate_rejects_unlisted_bearer_models(monkeypatch): + from routes import compare_routes + + endpoint = SimpleNamespace( + id="ep", + base_url="https://api.example.test/v1", + api_key=None, + endpoint_kind="api", + cached_models=json.dumps(["cached-model"]), + pinned_models=json.dumps(["allowed-model"]), + hidden_models=None, + is_enabled=True, + ) + monkeypatch.setattr(compare_routes, "SessionLocal", lambda: _EndpointDb(endpoint)) + manager = SimpleNamespace( + create_session=lambda **kwargs: pytest.fail("comparison session was created"), + ) + route = _endpoint(compare_routes.setup_compare_routes(manager), "/api/compare/start", "POST") + + with pytest.raises(HTTPException) as exc: + route( + request=_Request(), + prompt="compare", + model_a="cached-model", + model_b="allowed-model", + endpoint_a="", + endpoint_b="", + endpoint_a_id="ep", + endpoint_b_id="ep", + is_blind="true", + ) + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_compare_aliases_run_chat_scope_dependency(monkeypatch): + from routes import compare_routes + + router = compare_routes.setup_compare_routes(SimpleNamespace()) + app = FastAPI() + app.include_router(router) + headers = { + "x-api-token": "1", + "x-api-owner": "alice", + "x-api-scopes": "todos:read", + "authorization": "Bearer ody_test", + } + async with _client(_StateInjector(app)) as client: + requests = [ + client.post("/api/compare/start", data={"prompt": "x", "model_a": "a", "model_b": "b", "endpoint_a": "https://a.example", "endpoint_b": "https://b.example"}, headers=headers), + client.post("/api/compare/record", json={"prompt": "x", "models": ["a", "b"], "winner": "tie"}, headers=headers), + client.get("/api/compare/history", headers=headers), + client.post("/api/compare/abc/vote", data={"winner": "tie"}, headers=headers), + client.delete("/api/compare/abc", headers=headers), + ] + responses = await __import__("asyncio").gather(*requests) + assert all(response.status_code == 403 for response in responses) + + +@pytest.mark.asyncio +async def test_bearer_stream_skips_intent_classifier_and_tool_preprocessing(monkeypatch): + from routes import chat_helpers, chat_routes + from tests.test_foreground_model_routing import _chat_stream_endpoint + + calls = [] + captured = {} + endpoint = _chat_stream_endpoint( + monkeypatch, + "chat", + captured, + capture_completion=True, + capture_context=True, + ) + monkeypatch.setattr( + chat_routes, + "_classify_tool_intent", + lambda message: calls.append(message) or pytest.fail("bearer intent classifier ran"), + ) + + class _EmptyDb: + def query(self, *args, **kwargs): + return self + + def filter(self, *args, **kwargs): + return self + + def all(self): + return [] + + def close(self): + return None + + monkeypatch.setattr(chat_helpers, "SessionLocal", _EmptyDb) + request = SimpleNamespace( + headers={"authorization": "Bearer ody_test"}, + app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)), + state=SimpleNamespace( + api_token=True, + api_token_owner="alice", + api_token_scopes=["chat"], + current_user="api", + ), + _form={ + "message": "create a todo and use tools", + "session": "session-1", + "mode": "chat", + }, + ) + + async def form(): + return request._form + + request.form = form + response = await endpoint(request) + async for _ in response.body_iterator: + pass + + assert calls == [] + assert "chat" in captured + assert captured["build_context"]["allow_tool_preprocessing"] is False diff --git a/tests/test_api_token_security_cycle4.py b/tests/test_api_token_security_cycle4.py index 55bedc316..89117fceb 100644 --- a/tests/test_api_token_security_cycle4.py +++ b/tests/test_api_token_security_cycle4.py @@ -400,6 +400,10 @@ def test_explicit_bearer_model_does_not_require_live_setup_probe(monkeypatch): is_enabled=True, base_url="https://api.example.test/v1", api_key=None, + endpoint_kind="api", + cached_models=json.dumps(["provider-model"]), + pinned_models=json.dumps(["explicit-model"]), + hidden_models=None, ) monkeypatch.setattr(sr, "SessionLocal", lambda: _EndpointDb(endpoint))