mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 17:22:25 +01:00
feat(editor): harden photo editor security and interactions
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
Reference in New Issue
Block a user