{"record":{"id":"0e9ca6dad9699c9e","repo":"zeroclaw-labs/zeroclaw","slug":"permissiondenied","errorCode":"PermissionDenied","errorMessage":"Blocked redirect target: {err}","messagePattern":"Blocked redirect target: (.+?)","errorType":"validation","errorClass":"std::io::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-tools/src/web_fetch.rs","lineNumber":178,"sourceCode":"        let allowed_private_hosts = self.allowed_private_hosts.clone();\n        let pinned_host = target.host.clone();\n        let redirect_policy_rejected = Arc::new(AtomicBool::new(false));\n        let rejected_by_policy = Arc::clone(&redirect_policy_rejected);\n        let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {\n            if attempt.previous().len() >= 10 {\n                rejected_by_policy.store(true, Ordering::Relaxed);\n                return attempt.error(std::io::Error::other(\"Too many redirects (max 10)\"));\n            }\n\n            if let Err(err) = validate_redirect_target(\n                attempt.url().as_str(),\n                &pinned_host,\n                &allowed_domains,\n                &blocked_domains,\n                &allowed_private_hosts,\n            ) {\n                rejected_by_policy.store(true, Ordering::Relaxed);\n                return attempt.error(std::io::Error::new(\n                    std::io::ErrorKind::PermissionDenied,\n                    format!(\"Blocked redirect target: {err}\"),\n                ));\n            }\n\n            attempt.follow()\n        });\n\n        let builder = reqwest::Client::builder()\n            .no_proxy()\n            .timeout(Duration::from_secs(timeout_secs))\n            .connect_timeout(Duration::from_secs(10))\n            .redirect(redirect_policy)\n            .user_agent(\"ZeroClaw/0.1 (web_fetch)\");\n        let client = pin_resolved_host(builder, target).build()?;\n\n        Ok(RedirectGuardedClient {\n            client,","sourceCodeStart":160,"sourceCodeEnd":196,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-tools/src/web_fetch.rs#L160-L196","documentation":"web_fetch pins DNS at fetch time and installs a custom reqwest redirect policy (build_redirect_guarded_client, web_fetch.rs:153-185) that re-validates every hop via validate_redirect_target: the hop must stay on the exact pinned host (cross-host hops bail with \"Cross-host redirects are blocked so DNS validation remains pinned\") and re-pass the allowed/blocked-domain and private-host checks; a rejected hop becomes io::ErrorKind::PermissionDenied \"Blocked redirect target: {err}\". The same flag permanently disables the Firecrawl fallback for this fetch (should_fallback_to_firecrawl), so a denied URL is never handed to a third party. This is the tool's SSRF boundary, not a transient network fault.","triggerScenarios":"web_fetch execute() on a URL whose server replies 30x to a different host (even another subdomain — host_str must match the pinned host exactly), to a blocked_domains entry, or to a private/loopback IP not in allowed_private_hosts; also >10 redirect hops trips the sibling \"Too many redirects\" branch with the same flag semantics.","commonSituations":"Sites redirecting through CDN or tracker domains; a domain migration where the new host is not yet allowlisted; geo/region redirects; http->https or bare-domain->www hops that change host_str; internal hostnames resolving to private IPs.","solutions":["Fetch the redirect's final URL directly (take it from the error text or logs) after adding that host to web_fetch allowed_domains, if it is trusted","If the destination is legitimately internal/private, add the specific host to allowed_private_hosts instead of widening globally","Remove or narrow the blocked_domains entry if it over-matches the destination host","Do not try to bypass the pin via proxy or Firecrawl — the tool intentionally refuses fallback for policy denials (that refusal is the security property)"],"exampleFix":"# before — only the origin is allowlisted, but it 30x-redirects to a CDN host\n[tools.web_fetch]\nallowed_domains = [\"docs.example.com\"]\n\n# after — allow the trusted redirect destination too (same-host hops need nothing)\n[tools.web_fetch]\nallowed_domains = [\"docs.example.com\", \"cdn.example.com\"]","handlingStrategy":"validation","validationCode":"// mirror the policy before fetching: same host string, http(s) only\nfn redirect_would_pass(url: &str, pinned_host: &str) -> bool {\n    match reqwest::Url::parse(url) {\n        Ok(u) => u.host_str() == Some(pinned_host),\n        Err(_) => false,\n    }\n}","typeGuard":"fn is_policy_denied(err: &reqwest::Error) -> bool {\n    err.source()\n        .and_then(|s| s.downcast_ref::<std::io::Error>())\n        .map(|io| io.kind() == std::io::ErrorKind::PermissionDenied)\n        .unwrap_or(false)\n}","tryCatchPattern":"match tool.execute(url).await {\n    Ok(result) => result,\n    Err(e) if e.to_string().contains(\"Blocked redirect target\") => {\n        // security denial: never retry via proxies/Firecrawl; fix the allowlist or fetch the final URL directly\n    }\n    Err(e) => return Err(e),\n}","preventionTips":["Curate allowed_domains to include the full trusted redirect chain of sites you fetch routinely","Prefer allowing specific internal hostnames in allowed_private_hosts over broadening the whole policy","Extract the blocked target from the error text to make allowlist fixes precise","Treat any urge to bypass this denial as a design smell — the pin is the SSRF boundary"],"tags":["rust","web-fetch","ssrf","redirect","security","allowlist","permission-denied"],"backgroundTag":"ssrf-redirect-blocked","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}