clockworklabs/SpacetimeDB · error · io::Error

refusing to connect to private or special-purpose addresses

Error message

refusing to connect to private or special-purpose addresses

What it means

SpacetimeDB's host environment installs a hardened reqwest DNS resolver (FilteredDnsResolver) for HTTP requests made from reducers/procedures. It resolves the hostname, then filters out every address that falls in a private or special-purpose range (loopback, link-local, private nets, multicast, etc., per is_blocked_ip at crates/core/src/host/instance_env.rs:1053). If the resolver strips ALL resolved addresses, the request fails with io::ErrorKind::PermissionDenied and this message. It is an anti-SSRF guard: hosted module code must not reach the host's internal network.

Solutions

  1. Point the module at a publicly reachable endpoint (public DNS name resolving to a public IP) — internal addresses are blocked by design
  2. Verify what the hostname actually resolves to (dig/host) from the machine running the SpacetimeDB host; if it resolves internally, fix DNS or move the service
  3. If you control the host, expose the service on a routable address and, where the deployment supports it, allowlist it per the host's documented HTTP policy
  4. Never attempt to bypass the filter (e.g. hex/decimal IP encodings); blocked ranges are also matched after DNS, so rebinding does not help

Example fix

// before (in module/reducer code)
let res = reqwest::get("http://localhost:9090/api").await; // PermissionDenied: filtered resolver

// after
let res = reqwest::get("https://api.example.com/endpoint").await; // public address passes FilteredDnsResolver
Defensive patterns

Strategy: validation

Validate before calling

// Before issuing the request from module/host code:
use std::net::{IpAddr, ToSocketAddrs};
fn resolves_only_to_blocked(host: &str) -> bool {
    let addrs: Vec<_> = (host, 0).to_socket_addrs().map(|it| it.collect()).unwrap_or_default();
    !addrs.is_empty()
        && addrs.iter().all(|a| a.ip().is_loopback() || a.ip().is_unspecified()
            || matches!(a.ip(), IpAddr::V4(v4) if v4.is_private() || v4.is_link_local()))
}
if resolves_only_to_blocked("my-service.internal") { return Err(anyhow::anyhow!("target would be SSRF-blocked")); }

Type guard

fn is_publicly_routable(ip: IpAddr) -> bool {
    !(ip.is_loopback() || ip.is_unspecified() || ip.is_multicast()
        || matches!(ip, IpAddr::V4(v4) if v4.is_private() || v4.is_link_local() || v4.is_broadcast())
        || matches!(ip, IpAddr::V6(v6) if (v6.segments()[0] & 0xfe00) == 0xfc00))
}

Try / catch

match reqwest::get(url).await {
    Err(e) if e.is_connect()
        && e.to_string().contains("private or special-purpose") => {
            // blocked by SSRF filter: fix the target address, do not retry
        }
    other => other?,
}

Prevention

When it happens

Trigger: Calling http_request / a reqwest client from module code toward a host whose DNS resolves only to blocked ranges: http://localhost:PORT, http://127.0.0.1, http://10.x.x.x, http://192.168.x.x, http://172.16-31.x.x, http://169.254.169.254 (cloud metadata), ::1, fc00::/7, or a public-looking domain that DNS-rebinds to an internal IP. IP-literal URLs are checked up-front by is_blocked_ip_literal and fail before DNS is even consulted.

Common situations: Developing against a local API (localhost:8080) or an in-cluster/LAN service from a module running on a SpacetimeDB host; containerized setups where the target service only binds loopback; accidentally pointing a module at a cloud metadata endpoint; DNS records that changed to point at an internal address.

Related errors


AI-assisted analysis of clockworklabs/SpacetimeDB@eddf9f5014 (2026-08-20). Data as JSON: /api/errors/86a50f55bd92d8d6. Report an issue: GitHub.

Appendix: source

Thrown at crates/core/src/host/instance_env.rs:1145

/// If the user requests a timeout longer than this, we will clamp to this value.
/// 180 seconds accommodates long-running LLM and AI API calls,
/// which routinely take 30-120 seconds for complex requests.
const HTTP_MAX_TIMEOUT: Duration = Duration::from_secs(180);
const MODULE_HTTP_DISABLED_ERROR: &str = "module outbound HTTP requests are disabled";
const BLOCKED_HTTP_ADDRESS_ERROR: &str = "refusing to connect to private or special-purpose addresses";

struct FilteredDnsResolver;

impl reqwest::dns::Resolve for FilteredDnsResolver {
    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
        let host = name.as_str().to_owned();
        Box::pin(async move {
            let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?;
            let filtered_addrs: Vec<SocketAddr> = addrs.filter(|addr| !is_blocked_ip(addr.ip())).collect();

            if filtered_addrs.is_empty() {
                return Err(
                    std::io::Error::new(std::io::ErrorKind::PermissionDenied, BLOCKED_HTTP_ADDRESS_ERROR).into(),
                );
            }

            Ok(Box::new(filtered_addrs.into_iter()) as reqwest::dns::Addrs)
        })
    }
}

fn is_blocked_ip_literal(url: &reqwest::Url) -> bool {
    match url.host() {
        Some(url::Host::Ipv4(ip)) => is_blocked_ip(IpAddr::V4(ip)),
        Some(url::Host::Ipv6(ip)) => is_blocked_ip(IpAddr::V6(ip)),
        Some(url::Host::Domain(_)) | None => false,
    }
}

fn is_blocked_ip(ip: IpAddr) -> bool {
    match ip {

View on GitHub (pinned to eddf9f5014)