Provider response URLs, model-supplied image refs, manifest-derived pet URLs, and remote sitemap <loc> entries were fetched with raw requests/httpx/urllib — bypassing tools/url_safety while every platform media path already uses it. A hostile or compromised provider/manifest endpoint could steer a server-side fetch at internal or metadata addresses; several sites cache the body where it is deliverable back. Apply the canonical is_safe_url + create_ssrf_safe_client pattern at every site: per-hop revalidation at TCP connect (closing the DNS-rebinding window), bounded redirect chains that fail closed on missing Location, and caller headers scoped to the first hop only — matching the openrouter provider's own documented contract that its bearer key must never leave the operator-selected host. Operator-configured endpoints and pinned release assets are out of scope — those URLs are operator-selected, not remote-party-controlled. Fixes #114468 Closes #44728 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> Co-authored-by: AlexFucuson9 <AlexFucuson9@users.noreply.github.com> Co-authored-by: Ray <rayjun0412@gmail.com> Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>
98 lines
4.5 KiB
Python
98 lines
4.5 KiB
Python
"""Image-generation JSON-RPC handler (ws twin of the image_generate tool) for UI surfaces
|
|
(avatar pickers, artifact panes). The result is a data URL: a remote desktop can't read a
|
|
gateway file path and hosted URLs are often CORS-opaque to a renderer canvas. Bodies are
|
|
rebound onto server.py's globals (method_ctx.bind_module) and reference them bare.
|
|
"""
|
|
|
|
from .method_ctx import HandlerRegistry, bind_module
|
|
|
|
_registry = HandlerRegistry()
|
|
method = _registry.method
|
|
|
|
|
|
def _image_to_data_url(ref: str, cap: int):
|
|
"""Fetch a URL or read a local path into a data URL; None when missing, over *cap*, or failing."""
|
|
import base64
|
|
import mimetypes
|
|
import os
|
|
try:
|
|
if ref.startswith(("http://", "https://")):
|
|
# Provider-result URLs are remote-party-controlled — same SSRF guard as
|
|
# agent/provider_media.save_url (which may have fallen back to the bare URL).
|
|
from tools.url_safety import create_ssrf_safe_client, is_safe_url
|
|
if not is_safe_url(ref):
|
|
return None
|
|
with create_ssrf_safe_client(timeout=60, follow_redirects=True) as client, \
|
|
client.stream("GET", ref, headers={"User-Agent": "hermes-agent"}) as resp:
|
|
resp.raise_for_status()
|
|
if resp.headers.get("content-length") and int(resp.headers["content-length"]) > cap:
|
|
return None
|
|
chunks, total = [], 0
|
|
for chunk in resp.iter_bytes():
|
|
total += len(chunk)
|
|
if total > cap:
|
|
return None
|
|
chunks.append(chunk)
|
|
data = b"".join(chunks)
|
|
mime = (resp.headers.get("content-type") or "image/png").split(";", 1)[0].strip()
|
|
elif os.path.isfile(ref):
|
|
if os.path.getsize(ref) > cap:
|
|
return None
|
|
with open(ref, "rb") as fh:
|
|
data = fh.read(cap + 1)
|
|
mime = mimetypes.guess_type(ref)[0] or "image/png"
|
|
else:
|
|
return None
|
|
if len(data) > cap:
|
|
return None
|
|
mime = mime if mime.startswith("image/") else "image/png"
|
|
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@method("image.generate")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Params: ``prompt`` (required unless ``probe``), ``aspect_ratio``
|
|
(landscape|square|portrait), ``probe`` (availability only), ``max_bytes`` (cap
|
|
on the data URL, default 8MB, max 16MB). Result: ``{available, success, image,
|
|
image_data, error}`` — ``image_data`` is omitted when the download fails, so
|
|
callers fall back to ``image`` (the backend's URL/path)."""
|
|
try:
|
|
from tools.image_generation_tool import check_image_generation_requirements
|
|
available = bool(check_image_generation_requirements())
|
|
except Exception:
|
|
available = False
|
|
if is_truthy_value(params.get("probe", False)):
|
|
return _ok(rid, {"available": available})
|
|
if not available:
|
|
return _ok(rid, {
|
|
"available": False, "success": False,
|
|
"error": "No image generation backend configured (run `hermes tools` to enable one)."})
|
|
prompt = str(params.get("prompt") or "").strip()
|
|
if not prompt:
|
|
return _err(rid, 4071, "prompt required")
|
|
aspect = str(params.get("aspect_ratio") or "square").strip().lower()
|
|
try:
|
|
cap = min(int(params.get("max_bytes", 8_000_000) or 8_000_000), 16_000_000)
|
|
except (TypeError, ValueError):
|
|
cap = 8_000_000
|
|
try:
|
|
# Full provider dispatcher — same path as the model tool (source-image confinement,
|
|
# plugin providers, managed routing, FAL fallback); the FAL leaf bypassed providers.
|
|
from tools.image_generation_tool import _handle_image_generate
|
|
result = json.loads(_handle_image_generate({"prompt": prompt, "aspect_ratio": aspect}))
|
|
except Exception as e:
|
|
return _err(rid, 5071, str(e))
|
|
if not result.get("success"):
|
|
return _ok(rid, {"available": True, "success": False,
|
|
"error": str(result.get("error") or "generation failed")})
|
|
image_ref = str(result.get("image") or "")
|
|
data_url = _image_to_data_url(image_ref, cap) if image_ref else None
|
|
return _ok(rid, {"available": True, "success": True, "image": image_ref,
|
|
**({"image_data": data_url} if data_url else {})})
|
|
|
|
|
|
def register(server) -> None:
|
|
bind_module(globals(), server, skip=("_",))
|