feat(editor): harden photo editor security and interactions

This commit is contained in:
Alexandre Teixeira
2026-10-28 17:37:10 +00:00
parent 0b8176b11e
commit c237a1cb9c
68 changed files with 3630 additions and 426 deletions
+86 -19
View File
@@ -25,7 +25,7 @@ import re
import uuid
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
from fastapi import APIRouter, Request
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from src import editor_style_guide as style_guide
@@ -1173,12 +1173,35 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
return out
def _user(request: Request) -> Optional[str]:
def _is_admin(request: Request) -> bool:
try:
from src.auth_helpers import get_current_user
return get_current_user(request)
from core.middleware import require_admin
require_admin(request)
return True
except Exception:
return False
def _user(request: Request) -> Optional[str]:
# Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
if getattr(request.state, "api_token", False):
from src.auth_helpers import require_api_token_scope
return require_api_token_scope(request, "chat")
from src.auth_helpers import get_current_user, _auth_disabled
u = get_current_user(request)
if u:
return u
if _auth_disabled():
return None
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
raise HTTPException(401, "Not authenticated")
client = getattr(request, "client", None)
host = (client.host if client else "") or ""
if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host:
return None
raise HTTPException(401, "Not authenticated")
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
@@ -1297,9 +1320,13 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception:
raw = None
_used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0]
else (agent_model or editor_agent_model.local_model()))
parsed = _parse_plan(raw)
if _used and _used[0]:
model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
if isinstance(parsed, dict):
parsed["agent_model"] = model_used
if mode != "check":
@@ -1494,6 +1521,12 @@ def setup_editor_agent_routes() -> APIRouter:
overwritten.
"""
owner = _user(request)
from src.settings import get_setting
disabled = get_setting("disabled_tools", []) or []
if not isinstance(disabled, (list, tuple, set)):
disabled = []
if "plan_edit" in disabled:
raise HTTPException(403, "Editor tool is disabled")
try:
body = await request.json()
except Exception:
@@ -1506,7 +1539,7 @@ def setup_editor_agent_routes() -> APIRouter:
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
# Optional user-picked PLANNER model (the editor agent/planner model).
agent_model = str(body.get("agent_model") or "").strip()
tools_offered = _scoped_editor_tools()
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
scoped = _scoped(session, owner)
trace: List[Dict[str, Any]] = [
{"type": "session", "session": scoped, "tools_offered": tools_offered},
@@ -1540,9 +1573,14 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception:
raw = None
_used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0]
else (agent_model or editor_agent_model.local_model()))
plan = _parse_plan(raw) or _fallback_plan(prompt)
parsed = _parse_plan(raw)
if _used and _used[0]:
model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
plan = parsed or _fallback_plan(prompt)
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
if _strengthened != plan.get("steps"):
plan = {**plan, "steps": _strengthened,
@@ -1618,6 +1656,12 @@ def setup_editor_agent_routes() -> APIRouter:
"results": [],
"agent_model": model_used,
})
needed = {"plan_edit"}
for step in steps:
op = step.get("op")
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
if needed.intersection(disabled):
raise HTTPException(403, "Editor tool is disabled")
results: List[Dict[str, Any]] = []
for step in steps:
op = step.get("op")
@@ -1726,6 +1770,8 @@ def setup_editor_agent_routes() -> APIRouter:
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
"""
owner = _user(request)
from src.tool_security import owner_is_admin_or_single_user
is_admin = owner_is_admin_or_single_user(owner)
models: List[Dict[str, Any]] = []
seen: set = set()
try:
@@ -1748,22 +1794,25 @@ def setup_editor_agent_routes() -> APIRouter:
# recognisably image-capable — the same test the request
# router uses.
image_endpoint = getattr(ep, "model_type", None) == "image"
provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image"
endpoint_id = getattr(ep, "id", None)
provider = getattr(ep, "name", None) or (f"Endpoint {endpoint_id}" if endpoint_id else "image")
for name in _endpoint_enabled_models(ep):
if not image_endpoint and not looks_like_image_generation_model(name):
continue
key = (provider, name)
key = (str(endpoint_id), name)
if key in seen:
continue
seen.add(key)
models.append({
entry = {
"id": name,
"label": name,
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
"provider": provider,
"endpoint_id": getattr(ep, "id", None),
"base_url": getattr(ep, "base_url", "") or "",
})
"endpoint_id": endpoint_id,
}
if is_admin:
entry["base_url"] = getattr(ep, "base_url", "") or ""
models.append(entry)
finally:
db.close()
except Exception:
@@ -1774,21 +1823,39 @@ def setup_editor_agent_routes() -> APIRouter:
async def editor_agent_models(request: Request) -> JSONResponse:
"""List the models the editor agent/planner can run on.
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is
Each entry is ``{id, label, source, vision}`` where ``id`` is
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
``hosted`` (the configured default/utility chat model that the planner
falls back to).
For non-admin users, private URLs and internal probe status are omitted.
"""
owner = _user(request)
from src.tool_security import owner_is_admin_or_single_user
is_admin = owner_is_admin_or_single_user(owner)
try:
models = await editor_agent_model.list_editor_agent_models(owner)
except Exception:
logger.warning("editor agent-models lookup failed", exc_info=True)
models = []
safe_models = []
for m in models:
entry = dict(m)
if not is_admin:
entry.pop("base_url", None)
safe_models.append(entry)
probe_info = {}
if is_admin:
probe_info = editor_agent_model.probe_status()
if not probe_info:
editor_agent_model.startup_probe()
probe_info = editor_agent_model.probe_status()
return JSONResponse({
"models": models,
"probe": editor_agent_model.probe_status(),
"models": safe_models,
"probe": probe_info,
})
@router.post("/api/editor/self-check")
+29 -15
View File
@@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
configured.update(v for v in values if isinstance(v, str))
except (TypeError, ValueError):
pass
if model not in configured or not looks_like_image_generation_model(model):
continue
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
if model:
if model not in configured or not looks_like_image_generation_model(model):
continue
else:
if not any(looks_like_image_generation_model(v) for v in configured):
continue
ep_id = str(getattr(ep, "id", "") or "")
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
if owner and getattr(ep, "owner", None) == owner:
return ep
if fallback is None:
@@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
)
if not ok:
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
async with httpx.AsyncClient(timeout=60) as c2:
async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
ir = await c2.get(url)
if ir.status_code == 200:
return base64.b64encode(ir.content).decode()
@@ -1727,7 +1733,7 @@ def setup_gallery_routes() -> APIRouter:
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
# SSRF hardening: validate a client-supplied endpoint before any
# outbound request (mirrors routes/embedding_routes.py).
if requested_base:
if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -2073,7 +2079,7 @@ def setup_gallery_routes() -> APIRouter:
runs off the event loop (large images).
"""
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
if requested_base:
if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -2166,26 +2172,34 @@ def setup_gallery_routes() -> APIRouter:
try:
ep = None
if endpoint_id:
ep = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id),
ModelEndpoint, user,
).first()
if ep is None and requested_base:
ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
if ep is None:
return {"available": False, "reason": "endpoint not found or disabled"}
elif requested_base:
ep = _visible_image_endpoint_for_base(db, requested_base, user)
if ep is None:
if ep is None:
return {"available": False, "reason": "endpoint not found or disabled"}
else:
ep = _first_visible_image_endpoint(db, user)
if ep is None:
return {"available": False, "reason": "no image endpoint configured"}
base = ep.base_url.rstrip("/")
finally:
db.close()
from src.url_safety import check_outbound_url
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
ok, reason = check_outbound_url(base, block_private=block_private)
if not ok:
return {"available": False, "reason": f"endpoint URL rejected: {reason}"}
if not base.endswith("/v1"):
base += "/v1"
# Dynamic path segment (validated token above), so not routed through
# _join_checked_gallery_endpoint's exact-match whitelist.
url = f"{base}/images/progress/{request_id}"
try:
async with httpx.AsyncClient(timeout=10) as client:
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
r = await client.get(url)
if r.status_code == 200:
data = r.json()
@@ -2223,7 +2237,7 @@ def setup_gallery_routes() -> APIRouter:
# below, so validate it first (mirrors routes/embedding_routes.py).
# Local-first means loopback/LAN is allowed by default; the cloud
# metadata range and non-HTTP(S) schemes are always rejected.
if requested_base:
if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -2250,7 +2264,7 @@ def setup_gallery_routes() -> APIRouter:
# so the outbound URL never depends directly on request-body input.
db = SessionLocal()
try:
ep = _visible_image_endpoint_for_base(db, requested_base, user)
ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
if not ep:
raise HTTPException(403, "Choose a registered image endpoint")
base = ep.base_url.rstrip("/")