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
- Point the module at a publicly reachable endpoint (public DNS name resolving to a public IP) — internal addresses are blocked by design
- 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
- 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
- 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
- Configure module HTTP targets as public DNS names, never localhost/LAN IPs
- Validate target hostnames in config at deploy time by resolving them and rejecting private/special-purpose results
- Remember DNS is re-checked after resolution: rebinding tricks still get filtered
- Watch for PermissionDenied on the request as the telltale sign of the filter, not a network outage
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
- AddColumns: table ` ` not found in new module def
- AddConstraint: constraint
- AddConstraint: ` ` not found in new module def
- AddIndex: index ` ` not found in new module def
- AddIndex: ` ` not found in new module def
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)