astrid-runtime/astrid · error · std::io::Error::PermissionDenied
DNS resolved to an unauthorized private or local IP address
Error message
DNS resolved to an unauthorized private or local IP address
What it means
The capsule's SSRF-safe DNS resolver (SafeDnsResolver::resolve, used as reqwest's custom resolver) filtered every address the DNS lookup returned through the airlock and none were allowed: at least one resolved address was private/loopback/link-local, so the fetch is rejected with PermissionDenied and the `tripped` flag is set so the host emits a typed `airlock-rejected` error. This prevents sandboxed WASM guests from reaching internal networks, cloud metadata endpoints, or localhost.
Source
Thrown at crates/astrid-capsule/src/engine/wasm/host/http/ssrf.rs:89
// not-found as some other kind is a safe degradation
// (falls back to a connection error). Mirrors the `tripped`
// recovery channel.
if lookup_err_is_not_found(&e) {
dns_failed.store(true, Ordering::Relaxed);
}
return Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>);
},
};
let (safe_addrs, saw_unsafe) = filter_safe_addrs(addrs, exempt);
if safe_addrs.is_empty() {
// All resolved addresses failed the airlock: a genuine SSRF
// block. Mark `tripped` so the caller can emit the typed
// `airlock-rejected` instead of a generic connection error.
if saw_unsafe {
tripped.store(true, Ordering::Relaxed);
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"DNS resolved to an unauthorized private or local IP address",
))
as Box<dyn std::error::Error + Send + Sync>);
}
// Resolved to an empty address set: an ordinary resolution miss,
// not an airlock block — mark `dns_failed`, not `tripped`.
dns_failed.store(true, Ordering::Relaxed);
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"host did not resolve to any address",
))
as Box<dyn std::error::Error + Send + Sync>);
}
let iter: reqwest::dns::Addrs = Box::new(safe_addrs.into_iter());
Ok(iter)
})View on GitHub (pinned to affd8760f4)
Solutions
- Use the operator's exempt-host/allowlist mechanism (exempt_host at pre-flight) to sanction the specific local endpoint the guest must reach
- Have the guest call the public hostname instead of an internal IP or localhost name
- If the endpoint must be internal, run the request outside the capsule airlock in trusted host code rather than guest code
- Do not weaken filter_safe_addrs — treat this error as a signal that guest code is attempting an SSRF-style request
Example fix
// before: guest fetches a local dev server
let resp = http.get("http://localhost:9000/api").send()?; // airlock-rejected
// after: register the endpoint as the sanctioned exempt host, then fetch the same name
SafeDnsResolver::with_exempt_host("localhost:9000");
let resp = http.get("http://localhost:9000/api").send()?; Defensive patterns
Strategy: validation
Validate before calling
// Before issuing the guest request, check the host will pass the airlock:
fn resolves_public(host: &str) -> bool {
tokio::net::lookup_host((host, 0u16))
.map(|addrs| addrs.all(|a| !is_private_or_local(a.ip())))
.unwrap_or(false)
} Type guard
fn is_public_addr(addr: &SocketAddr) -> bool {
!(addr.ip().is_loopback()
|| addr.ip().is_private()
|| addr.ip().is_link_local()
|| addr.ip().is_unspecified())
} Try / catch
match result {
Err(e) if e.to_string().contains("unauthorized private or local IP") => {
eprintln!("blocked by capsule airlock: host resolves to a private/local address");
// check tripped flag / `airlock-rejected` typed signal, do not retry
}
other => ignore_or_propagate(other),
} Prevention
- Never point guest code at localhost, 127.0.0.1, 169.254.169.254, or RFC1918 addresses
- Register any required local endpoint as the operator's exempt host before the request
- Treat this error as a possible SSRF attempt by guest code and audit the source
- Watch for DNS-rebinding: validate that public hostnames do not resolve to internal ranges
When it happens
Trigger: A guest makes an HTTP request to a hostname whose DNS records point only to private/loopback/link-local addresses (e.g. 127.0.0.1, 10.x, 169.254.169.254) and that host is not the operator's exempt/allowlisted host; also when DNS rebinding makes a public name resolve to an internal IP.
Common situations: Guest code fetching http://localhost:8080 or an internal service name like http://metadata.google.internal; corporate DNS resolving internal-only names for the sandbox; a redirect to an internal host; testing against a locally-running server without registering it as the exempt host.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- host did not resolve to any address
- sandbox {label} contains forbidden characters (double-quote,
- workspace CoW mask path does not exist: {} — refusing to spa
- process path {} overlaps a copy-on-write mask
- process path {} overlaps a sensitive runtime path
AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09).
Data as JSON: /api/errors/ff3cbca85d763247.
Report an issue: GitHub.