Security: block SSRF DNS-rebinding and widen the IP deny-set (#279)

Add is_forbidden_ip() (loopback, RFC1918, link-local incl. 169.254.169.254,
broadcast, documentation, 0.0.0.0/::, IPv6 unique-local fc00::/7, IPv4-mapped
IPv6) and SsrfGuardResolver (impl reqwest::dns::Resolve) that resolves and
rejects forbidden addresses at connect time, closing the DNS-rebinding bypass.
Wired into the navigation client and the JS fetch()/XHR path. The
--allow-private-network / OBSCURA_ALLOW_PRIVATE_NETWORK escape hatch is kept.

Co-authored-by: charlesmamane26-sketch <[email protected]>
This commit is contained in:
charlesmamane26-sketchandcharlesmamane26-sketch authored and GitHub committed 2026-06-10 19:09:15 +00:00
1 parent 38fa85946c
commit 6ba3c63596
3 files changed
+187 -16

No files matched your search

+6 -7
View File
@@ -509,6 +509,10 @@ fn build_request_client(proxy_url: Option<&str>) -> Result<reqwest::Client, Stri
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_millis(timeout_ms))
// SSRF: same DNS-resolving guard as the navigation client, so a scripted
// fetch()/XHR cannot reach a private/loopback IP via a public hostname
// (env/--allow-private-network still relaxes it inside the resolver).
.dns_resolver(std::sync::Arc::new(obscura_net::SsrfGuardResolver::new(false)))
// Be explicit about pool size: default is unbounded which is fine,
// but pool_idle_timeout default (90s) is short for SPA-heavy
// workloads where the same origin is hit dozens of times across
@@ -893,12 +897,7 @@ fn validate_fetch_url(url: &url::Url) -> Result<(), String> {
if let Some(host) = url.host() {
match host {
url::Host::Ipv4(ip) => {
if ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_documentation()
{
if obscura_net::is_forbidden_ip(std::net::IpAddr::V4(ip)) {
return Err(format!(
"Access to private/internal IP address {} is not allowed",
ip
@@ -906,7 +905,7 @@ fn validate_fetch_url(url: &url::Url) -> Result<(), String> {
}
}
url::Host::Ipv6(ip) => {
if ip.is_loopback() || ip.is_unicast_link_local() {
if obscura_net::is_forbidden_ip(std::net::IpAddr::V6(ip)) {
return Err(format!(
"Access to private/internal IPv6 address {} is not allowed",
ip