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

  1. 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
  2. If the destination is legitimately internal/private, add the specific host to allowed_private_hosts instead of widening globally
  3. Remove or narrow the blocked_domains entry if it over-matches the destination host
  4. 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

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


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