fix: validate client-supplied image _endpoint to prevent SSRF (gallery proxies) (#1718)

POST /api/image/harmonize and POST /api/image/inpaint read an `_endpoint` from
the request body and issue server-side httpx POSTs to it with no validation. A
caller can set `_endpoint` to http://169.254.169.254/ (cloud instance metadata)
or any internal/loopback address the server can reach, turning these routes into
an SSRF primitive.

routes/embedding_routes.py already runs its user-supplied endpoint through
src.url_safety.check_outbound_url; these two routes were missing the same guard.
Validate `_endpoint` the same way before any outbound request: non-HTTP(S)
schemes and the link-local metadata range are always rejected, and
IMAGE_BLOCK_PRIVATE_IPS=true blocks private/loopback for full lockdown (the
local-first default still allows LAN diffusion servers).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mubashir R
2026-06-03 09:34:17 +05:00
committed by GitHub
parent 4baf168df0
commit 319ba50a44
2 changed files with 66 additions and 0 deletions
+22
View File
@@ -923,6 +923,16 @@ def setup_gallery_routes() -> APIRouter:
body = await request.json()
# Use endpoint from request body (editor dropdown) or fall back to DB lookup
base = (body.pop("_endpoint", "") or "").rstrip("/")
# SSRF hardening: validate a client-supplied endpoint before any
# outbound request (mirrors routes/embedding_routes.py).
if base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
base,
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
chosen_model = (body.pop("_model", "") or "").strip()
api_key = None
if not base:
@@ -1115,6 +1125,18 @@ def setup_gallery_routes() -> APIRouter:
raise HTTPException(400, "No image provided")
endpoint = (body.get("_endpoint") or "").rstrip("/")
# SSRF hardening: a client-supplied endpoint is fetched server-side
# 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 endpoint:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
endpoint,
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
model = (body.get("_model") or "").strip()
base = endpoint