fix(security): close bearer alias boundaries

This commit is contained in:
RaresKeY
2026-08-30 17:52:35 +00:00
parent 0b32ecea50
commit a09b0b5722
13 changed files with 1200 additions and 107 deletions
+44
View File
@@ -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.
+16 -1
View File
@@ -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)
+40 -1
View File
@@ -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:
+67 -15
View File
@@ -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()
+92 -38
View File
@@ -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)
+69 -1
View File
@@ -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:
+9 -1
View File
@@ -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
+40 -9
View File
@@ -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")