zeroclaw-labs/zeroclaw · error · anyhow::Error
Blocked local/private host: {display_host}
Error message
Blocked local/private host: {display_host} What it means
This is the text browser's SSRF gate. validate_text_browser_url resolves the host, asks domain_guard::is_private_or_local_host to flag loopback, RFC1918, link-local, cloud metadata, and .local addresses, and denies them unless the operator explicitly listed the host in allowed_private_hosts. The message reports the display form of the blocked host.
Source
Thrown at crates/zeroclaw-tools/src/text_browser.rs:256
.ok_or_else(|| anyhow::Error::msg("URL must include a host"))?;
let bare_host = host_str.trim_start_matches('[').trim_end_matches(']');
let is_ipv6 = bare_host.parse::<std::net::Ipv6Addr>().is_ok();
let (host, display_host) = if is_ipv6 {
let bare = bare_host.parse::<std::net::Ipv6Addr>().unwrap().to_string();
(bare.clone(), format!("[{bare}]"))
} else {
let h = host_str.to_lowercase();
(h.clone(), h)
};
// SSRF gate: deny by default for private/local hosts unless the operator
// explicitly listed them. Mirrors `browser`/`http_request`/`web_fetch`.
let private_host = domain_guard::is_private_or_local_host(&host);
let host_allowed = domain_guard::host_matches_allowlist(&host, allowed_private_hosts);
if private_host && !host_allowed {
anyhow::bail!("Blocked local/private host: {display_host}");
}
validate_dns(&host, host_allowed)?;
Ok(url.to_string())
}
#[async_trait]
impl Tool for TextBrowserTool {
fn name(&self) -> &str {
"text_browser"
}
fn description(&self) -> &str {
"Render a web page as plain text using a text-based browser (lynx, links, or w3m). \
Ideal for headless/SSH environments without a graphical browser. \
Auto-detects available browser or uses a configured preference. \
For untrusted URLs, prefer web_fetch because external browsers can re-resolve DNS and \View on GitHub (pinned to 88bb9c8533)
Solutions
- If the private target is intentional, have the operator add its exact host to the text browser's allowed_private_hosts config and retry
- Prefer a public hostname or tunnel (e.g. a public forwarding URL) for dev servers instead of allowlisting
- Never widen the allowlist to satisfy untrusted fetched content — a request for localhost from a web page is the attack working as intended
Example fix
// config: allow an intentional local dev server (operator decision)
// [tools.text_browser]
// allowed_private_hosts = ["localhost"]
// before
{"url":"http://localhost:3000/health"} // Blocked local/private host: localhost
// after (with allowlist entry present)
{"url":"http://localhost:3000/health"} // passes Defensive patterns
Strategy: validation
Validate before calling
let host = reqwest::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string));
if host.as_deref().map(domain_guard::is_private_or_local_host) == Some(true) {
// only proceed if the operator allowlisted this exact host
} Type guard
fn is_public_host(u: &str) -> bool {
reqwest::Url::parse(u).ok()
.and_then(|p| p.host_str().map(|h| !domain_guard::is_private_or_local_host(h)))
.unwrap_or(false)
} Try / catch
Err(e) if e.to_string().starts_with("Blocked local/private host") => {
// intentional local target: require operator allowlist entry, else surface as blocked-by-policy
} Prevention
- Treat any request for localhost/private IPs from fetched content as hostile
- Keep allowed_private_hosts minimal and explicit in operator config
- Use public hostnames or tunnels for dev servers instead of widening the allowlist
- Never widen the allowlist in response to untrusted input
When it happens
Trigger: Fetching http://localhost:3000, http://127.0.0.1:8080, http://192.168.1.10/admin, http://169.254.169.254/latest/meta-data/, or http://myprinter.local/ without a matching allowlist entry.
Common situations: Developers testing the agent against a local dev server; home-lab setups reaching 192.168.x devices; in the threat model, an attacker embedding internal URLs in fetched content hoping the agent will follow them — which this default-deny blocks.
Related errors
- Blocked local/private host: {host}
- Generated image URL targets a local or non-global host
- Blocked marker redirect to private or local host ({host}); r
- PermissionDenied
- refusing non-HTTPS attachment URL: {url}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/552595bc4a73dd98.
Report an issue: GitHub.