{"record":{"id":"206c27332794a805","repo":"zeroclaw-labs/zeroclaw","slug":"only-http-and-https-urls-are-allowed-206c27","errorCode":null,"errorMessage":"Only http:// and https:// URLs are allowed","messagePattern":"Only http:// and https:// URLs are allowed","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"warning","filePath":"crates/zeroclaw-tools/src/text_browser.rs","lineNumber":226,"sourceCode":"}\n\nfn validate_text_browser_url(\n    url: &str,\n    allowed_private_hosts: &[String],\n    validate_dns: impl FnOnce(&str, bool) -> anyhow::Result<()>,\n) -> anyhow::Result<String> {\n    let url = url.trim();\n\n    if url.is_empty() {\n        anyhow::bail!(\"URL cannot be empty\");\n    }\n\n    if url.chars().any(char::is_whitespace) {\n        anyhow::bail!(\"URL cannot contain whitespace\");\n    }\n\n    if !url.starts_with(\"http://\") && !url.starts_with(\"https://\") {\n        anyhow::bail!(\"Only http:// and https:// URLs are allowed\");\n    }\n\n    let parsed = reqwest::Url::parse(url)\n        .map_err(|e| anyhow::Error::msg(format!(\"Invalid URL format: {e}\")))?;\n\n    if !parsed.username().is_empty() || parsed.password().is_some() {\n        anyhow::bail!(\"URL userinfo is not allowed\");\n    }\n\n    let host_str = parsed\n        .host_str()\n        .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}]\"))","sourceCodeStart":208,"sourceCodeEnd":244,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-tools/src/text_browser.rs#L208-L244","documentation":"validate_text_browser_url only permits URLs whose string form starts with http:// or https://. Any other scheme — file://, ftp://, data:, gopher://, about:, or an uppercase HTTP:// — fails this prefix check before reqwest::Url::parse is even attempted.","triggerScenarios":"Passing \"ftp://mirror.example.com/file\", \"file:///etc/passwd\", \"data:text/html,...\", or \"HTTP://EXAMPLE.COM\" (the check is case-sensitive on the scheme); relative URLs like \"/docs/index.html\" also fail.","commonSituations":"Users pasting links from file managers or older docs that use ftp; attempts to read local files through the browser tool (blocked by design); uppercase schemes from auto-generated URLs that were never normalized to lowercase.","solutions":["Use an http:// or https:// URL","Normalize the scheme to lowercase before calling the tool","For local file reading, use the dedicated file tools — the text browser intentionally refuses other schemes"],"exampleFix":"// before\n{\"url\":\"HTTP://Example.COM/docs\"}\n// after\n{\"url\":\"http://example.com/docs\"}","handlingStrategy":"validation","validationCode":"let normalized = url.trim().to_ascii_lowercase();\nif !(normalized.starts_with(\"http://\") || normalized.starts_with(\"https://\")) { /* reject */ }","typeGuard":"fn is_http_url(u: &str) -> bool {\n    let u = u.trim().to_ascii_lowercase();\n    u.starts_with(\"http://\") || u.starts_with(\"https://\")\n}","tryCatchPattern":"Err(e) if e.to_string().starts_with(\"Only http://\") => {\n    // non-http schemes are unsupported by design: reject the input, do not retry\n}","preventionTips":["Lowercase the scheme before validation to avoid case-sensitive surprises","Route local-file needs to file tools, never through the text browser","Validate schemes at input ingestion, not at fetch time"],"tags":["url","scheme","validation","ssrf"],"backgroundTag":"unsupported-url-scheme","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}