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
+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("/")