{"record":{"id":"552595bc4a73dd98","repo":"zeroclaw-labs/zeroclaw","slug":"blocked-local-private-host-display-host","errorCode":null,"errorMessage":"Blocked local/private host: {display_host}","messagePattern":"Blocked local/private host: (.+?)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-tools/src/text_browser.rs","lineNumber":256,"sourceCode":"        .ok_or_else(|| anyhow::Error::msg(\"URL must include a host\"))?;\n\n    let bare_host = host_str.trim_start_matches('[').trim_end_matches(']');\n    let is_ipv6 = bare_host.parse::<std::net::Ipv6Addr>().is_ok();\n    let (host, display_host) = if is_ipv6 {\n        let bare = bare_host.parse::<std::net::Ipv6Addr>().unwrap().to_string();\n        (bare.clone(), format!(\"[{bare}]\"))\n    } else {\n        let h = host_str.to_lowercase();\n        (h.clone(), h)\n    };\n\n    // SSRF gate: deny by default for private/local hosts unless the operator\n    // explicitly listed them. Mirrors `browser`/`http_request`/`web_fetch`.\n    let private_host = domain_guard::is_private_or_local_host(&host);\n    let host_allowed = domain_guard::host_matches_allowlist(&host, allowed_private_hosts);\n\n    if private_host && !host_allowed {\n        anyhow::bail!(\"Blocked local/private host: {display_host}\");\n    }\n\n    validate_dns(&host, host_allowed)?;\n\n    Ok(url.to_string())\n}\n\n#[async_trait]\nimpl Tool for TextBrowserTool {\n    fn name(&self) -> &str {\n        \"text_browser\"\n    }\n\n    fn description(&self) -> &str {\n        \"Render a web page as plain text using a text-based browser (lynx, links, or w3m). \\\n         Ideal for headless/SSH environments without a graphical browser. \\\n         Auto-detects available browser or uses a configured preference. \\\n         For untrusted URLs, prefer web_fetch because external browsers can re-resolve DNS and \\","sourceCodeStart":238,"sourceCodeEnd":274,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-tools/src/text_browser.rs#L238-L274","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// config: allow an intentional local dev server (operator decision)\n// [tools.text_browser]\n// allowed_private_hosts = [\"localhost\"]\n// before\n{\"url\":\"http://localhost:3000/health\"}  // Blocked local/private host: localhost\n// after (with allowlist entry present)\n{\"url\":\"http://localhost:3000/health\"}  // passes","handlingStrategy":"validation","validationCode":"let host = reqwest::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string));\nif host.as_deref().map(domain_guard::is_private_or_local_host) == Some(true) {\n    // only proceed if the operator allowlisted this exact host\n}","typeGuard":"fn is_public_host(u: &str) -> bool {\n    reqwest::Url::parse(u).ok()\n        .and_then(|p| p.host_str().map(|h| !domain_guard::is_private_or_local_host(h)))\n        .unwrap_or(false)\n}","tryCatchPattern":"Err(e) if e.to_string().starts_with(\"Blocked local/private host\") => {\n    // intentional local target: require operator allowlist entry, else surface as blocked-by-policy\n}","preventionTips":["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"],"tags":["ssrf","security","url","network","allowlist"],"backgroundTag":"ssrf-private-host-blocked","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}