zeroclaw-labs/zeroclaw · error · std::io::Error
PermissionDenied
PermissionDenied
Error message
Blocked redirect target: {err} What it means
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.
Source
Thrown at crates/zeroclaw-tools/src/web_fetch.rs:178
let allowed_private_hosts = self.allowed_private_hosts.clone();
let pinned_host = target.host.clone();
let redirect_policy_rejected = Arc::new(AtomicBool::new(false));
let rejected_by_policy = Arc::clone(&redirect_policy_rejected);
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= 10 {
rejected_by_policy.store(true, Ordering::Relaxed);
return attempt.error(std::io::Error::other("Too many redirects (max 10)"));
}
if let Err(err) = validate_redirect_target(
attempt.url().as_str(),
&pinned_host,
&allowed_domains,
&blocked_domains,
&allowed_private_hosts,
) {
rejected_by_policy.store(true, Ordering::Relaxed);
return attempt.error(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("Blocked redirect target: {err}"),
));
}
attempt.follow()
});
let builder = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(10))
.redirect(redirect_policy)
.user_agent("ZeroClaw/0.1 (web_fetch)");
let client = pin_resolved_host(builder, target).build()?;
Ok(RedirectGuardedClient {
client,View on GitHub (pinned to 88bb9c8533)
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)
Example fix
# before — only the origin is allowlisted, but it 30x-redirects to a CDN host [tools.web_fetch] allowed_domains = ["docs.example.com"] # after — allow the trusted redirect destination too (same-host hops need nothing) [tools.web_fetch] allowed_domains = ["docs.example.com", "cdn.example.com"]
Defensive patterns
Strategy: validation
Validate before calling
// mirror the policy before fetching: same host string, http(s) only
fn redirect_would_pass(url: &str, pinned_host: &str) -> bool {
match reqwest::Url::parse(url) {
Ok(u) => u.host_str() == Some(pinned_host),
Err(_) => false,
}
} Type guard
fn is_policy_denied(err: &reqwest::Error) -> bool {
err.source()
.and_then(|s| s.downcast_ref::<std::io::Error>())
.map(|io| io.kind() == std::io::ErrorKind::PermissionDenied)
.unwrap_or(false)
} Try / catch
match tool.execute(url).await {
Ok(result) => result,
Err(e) if e.to_string().contains("Blocked redirect target") => {
// security denial: never retry via proxies/Firecrawl; fix the allowlist or fetch the final URL directly
}
Err(e) => return Err(e),
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- Cross-host redirects are blocked so DNS validation remains p
- xAI OAuth discovery returned untrusted {label}: {endpoint}
- Blocked local/private host: {host}
- Generated image URL targets a local or non-global host
- Generated image URL targets a cloud metadata host
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/0e9ca6dad9699c9e.
Report an issue: GitHub.