zeroclaw-labs/zeroclaw · error
Generated image URL targets a local or non-global host
Error message
Generated image URL targets a local or non-global host
What it means
The image-generation pipeline only downloads from globally routable public hosts. After normalize_domain canonicalizes the host, domain_guard::is_private_or_local_host rejects loopback (127.0.0.0/8, ::1), RFC 1918 private ranges (10/8, 172.16/12, 192.168/16), link-local, CGNAT 100.64.0.0/10, 0.0.0.0/8, multicast/reserved, documentation and benchmark ranges, IPv6 ULA/site-local, plus the names 'localhost', '*.localhost' and '*.local' (mDNS). This is the SSRF boundary: the agent must never be able to pull a 'generated image' from an internal network host. Note the image path has no private-resolution opt-in, unlike http_request.
Source
Thrown at crates/zeroclaw-tools/src/image_gen.rs:48
let mut url = reqwest::Url::parse(raw_url).context("Invalid generated image URL")?;
if url.scheme() != "https" {
anyhow::bail!("Generated image URL must use HTTPS");
}
if !url.username().is_empty() || url.password().is_some() {
anyhow::bail!("Generated image URL userinfo is not allowed");
}
let request_host = url
.host_str()
.ok_or_else(|| anyhow::Error::msg("Generated image URL must include a host"))?;
if request_host.ends_with('.') {
anyhow::bail!("Generated image URL host must not end with a dot");
}
let host = domain_guard::normalize_domain(request_host)
.ok_or_else(|| anyhow::Error::msg("Generated image URL host is invalid"))?;
let ip_literal = host.parse::<IpAddr>().ok();
if domain_guard::is_private_or_local_host(&host) {
anyhow::bail!("Generated image URL targets a local or non-global host");
}
if ip_literal.is_some_and(domain_guard::is_cloud_metadata_ip) {
anyhow::bail!("Generated image URL targets a cloud metadata host");
}
// Request the host that was validated, not the raw spelling. Normalization
// can change the host (a leading dot is stripped), and the DNS pin applied
// by `generated_image_client_with_builder` is keyed on the normalized host.
// Without this, `resolve_to_addrs` never matches the request host and
// reqwest silently falls back to its own unvalidated lookup. IP-literal
// hosts are left alone: they carry no DNS pin, and `set_host` rejects the
// unbracketed IPv6 form that normalization produces.
if ip_literal.is_none() {
url.set_host(Some(&host))
.map_err(|_| anyhow::Error::msg("Generated image URL host is invalid"))?;
}
let port = urlView on GitHub (pinned to 88bb9c8533)
Solutions
- Point the image host at a public, globally routable address (public CDN or object storage).
- For local testing, expose the mock through a public HTTPS tunnel or public test host instead of a private address.
- If you expected this to be allowed via a private-resolution policy, note the image_gen path always enforces public IPs; route such downloads through http_request with its explicit private-resolution allowance if your security policy permits.
Example fix
# before https://10.0.0.5:8443/generated/img.png # rejected # after https://public-cdn.example.com/generated/img.png # accepted
Defensive patterns
Strategy: validation
Validate before calling
use std::net::IpAddr;
fn is_public_host(host: &str) -> bool {
let h = host.trim_end_matches('.').to_ascii_lowercase();
if h == "localhost" || h.ends_with(".localhost") || h.ends_with(".local") { return false; }
match h.parse::<IpAddr>() {
Ok(ip) => !(ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() || ip.is_multicast()),
Err(_) => true, // names are checked after DNS resolution by the tool itself
}
} Type guard
fn is_fetchable_image_host(host: &str) -> bool { is_public_host(host) } Try / catch
match validate_image_target(url, nat64).await {
Err(e) if e.to_string().contains("local or non-global host") => {
// intended target is internal: route via an explicit, policy-approved path; never bypass the guard
}
other => other,
} Prevention
- Serve generated assets from public, globally routable hosts only; the image pipeline has no private-address opt-in.
- For local testing, expose mocks through a public HTTPS endpoint instead of 10.x/192.168.x/localhost.
- Remember names resolving to private IPs are blocked later at validate_resolved_ips_are_public — keep split-horizon DNS out of the asset path.
When it happens
Trigger: The fal.ai response contains a URL whose host is a private IP literal (https://10.0.0.5/img.png), 'localhost', a '*.local' mDNS name, or a custom storage domain that is literally one of the blocked forms. Hostnames that merely resolve to private IPs fail later, in validate_resolved_ips_are_public, not here.
Common situations: Self-hosted fal.ai or custom image storage on an internal network, local mocks and test servers used during development, and attempts (accidental or malicious via prompt injection) to make the agent fetch from internal endpoints.
Related errors
- Generated image URL targets a cloud metadata host
- Generated image URL userinfo is not allowed
- Blocked local/private host: {display_host}
- Blocked marker redirect to private or local host ({host}); r
- PermissionDenied
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/76ca2cb68dd36c6e.
Report an issue: GitHub.