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

  1. If the private target is intentional, have the operator add its exact host to the text browser's allowed_private_hosts config and retry
  2. Prefer a public hostname or tunnel (e.g. a public forwarding URL) for dev servers instead of allowlisting
  3. 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

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


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/552595bc4a73dd98. Report an issue: GitHub.