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 <charlesmamane1826@gmail.com>
This commit is contained in:
1 parent
38fa85946c
commit
6ba3c63596
3 files changed
+187
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -2,6 +2,9 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT};
|
||||
use reqwest::redirect::Policy;
|
||||
use reqwest::{Client, Method};
|
||||
@@ -88,6 +91,84 @@ pub fn env_allows_private_network() -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `ip` must never be the target of an outbound request from the
|
||||
/// engine: loopback, RFC1918 private, link-local (incl. the 169.254.169.254
|
||||
/// cloud-metadata endpoint), broadcast, documentation, the unspecified address
|
||||
/// (0.0.0.0 / ::, which the OS routes to localhost), IPv6 unique-local
|
||||
/// (fc00::/7), and any IPv4-mapped/compatible IPv6 form of the above.
|
||||
/// Centralizes the SSRF deny-set so the literal-host check and the
|
||||
/// DNS-resolution check (`SsrfGuardResolver`) can never disagree.
|
||||
pub fn is_forbidden_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_broadcast()
|
||||
|| v4.is_documentation()
|
||||
|| v4.is_unspecified()
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
if v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_unique_local()
|
||||
|| v6.is_unicast_link_local()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Unwrap IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d)
|
||||
// forms and re-check the embedded v4 so e.g. [::ffff:127.0.0.1] or
|
||||
// [::ffff:169.254.169.254] cannot slip past the v6 arm.
|
||||
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
|
||||
return is_forbidden_ip(IpAddr::V4(v4));
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// reqwest DNS resolver that performs the lookup and then rejects the whole
|
||||
/// request if ANY resolved address is in the SSRF deny-set. This closes the
|
||||
/// DNS-rebinding bypass a host-string check alone cannot: a public name that
|
||||
/// resolves to 127.0.0.1 / 169.254.169.254 / an RFC1918 address is blocked at
|
||||
/// connect time, using the very addresses reqwest will dial. When private
|
||||
/// access is permitted (`--allow-private-network` or
|
||||
/// `OBSCURA_ALLOW_PRIVATE_NETWORK`) the lookup passes through unfiltered.
|
||||
pub struct SsrfGuardResolver {
|
||||
allow_private: bool,
|
||||
}
|
||||
|
||||
impl SsrfGuardResolver {
|
||||
pub fn new(allow_private: bool) -> Self {
|
||||
Self { allow_private }
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve for SsrfGuardResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let allow = self.allow_private || env_allows_private_network();
|
||||
let host = name.as_str().to_string();
|
||||
Box::pin(async move {
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
|
||||
.collect();
|
||||
if !allow {
|
||||
if let Some(bad) = addrs.iter().find(|sa| is_forbidden_ip(sa.ip())) {
|
||||
return Err(format!(
|
||||
"SSRF blocked: '{}' resolves to forbidden address {}",
|
||||
host,
|
||||
bad.ip()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
let iter: Addrs = Box::new(addrs.into_iter());
|
||||
Ok(iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_url(url: &Url, allow_private_network: bool) -> Result<(), ObscuraNetError> {
|
||||
let allow_private_network = allow_private_network || env_allows_private_network();
|
||||
let scheme = url.scheme();
|
||||
@@ -105,12 +186,7 @@ fn validate_url(url: &Url, allow_private_network: bool) -> Result<(), ObscuraNet
|
||||
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 is_forbidden_ip(IpAddr::V4(ip)) {
|
||||
return Err(ObscuraNetError::Network(format!(
|
||||
"Access to private/internal IP address {} is not allowed",
|
||||
ip
|
||||
@@ -118,7 +194,7 @@ fn validate_url(url: &Url, allow_private_network: bool) -> Result<(), ObscuraNet
|
||||
}
|
||||
}
|
||||
url::Host::Ipv6(ip) => {
|
||||
if ip.is_loopback() || ip.is_unicast_link_local() {
|
||||
if is_forbidden_ip(IpAddr::V6(ip)) {
|
||||
return Err(ObscuraNetError::Network(format!(
|
||||
"Access to private/internal IPv6 address {} is not allowed",
|
||||
ip
|
||||
@@ -239,6 +315,10 @@ impl ObscuraHttpClient {
|
||||
.redirect(Policy::none())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(false)
|
||||
// SSRF: resolve + validate every hostname against the deny-set
|
||||
// so a public name pointing at a private/loopback IP is rejected
|
||||
// at connect time (DNS-rebinding safe).
|
||||
.dns_resolver(Arc::new(SsrfGuardResolver::new(self.allow_private_network)))
|
||||
;
|
||||
|
||||
if let Some(ref proxy) = self.proxy_url {
|
||||
@@ -526,3 +606,95 @@ pub enum ObscuraNetError {
|
||||
#[error("Request blocked: {0}")]
|
||||
Blocked(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ssrf_tests {
|
||||
use super::{is_forbidden_ip, validate_url, SsrfGuardResolver};
|
||||
use reqwest::dns::{Name, Resolve};
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use url::Url;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
IpAddr::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_private_and_special_ranges_are_forbidden() {
|
||||
for s in [
|
||||
"127.0.0.1",
|
||||
"127.5.6.7",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"192.168.1.1",
|
||||
"169.254.169.254", // cloud-metadata endpoint
|
||||
"0.0.0.0", // unspecified -> localhost (was a bypass)
|
||||
"255.255.255.255", // broadcast
|
||||
"192.0.2.1", // documentation
|
||||
] {
|
||||
assert!(is_forbidden_ip(ip(s)), "{s} should be forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_ipv4_is_allowed() {
|
||||
for s in ["1.1.1.1", "8.8.8.8", "93.184.216.34"] {
|
||||
assert!(!is_forbidden_ip(ip(s)), "{s} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_loopback_ula_linklocal_and_mapped_are_forbidden() {
|
||||
for s in [
|
||||
"::1", // loopback
|
||||
"::", // unspecified
|
||||
"fc00::1", // unique-local (was a bypass)
|
||||
"fd12:3456:789a::1", // unique-local
|
||||
"fe80::1", // link-local
|
||||
"::ffff:127.0.0.1", // v4-mapped loopback (was a bypass)
|
||||
"::ffff:169.254.169.254", // v4-mapped metadata
|
||||
] {
|
||||
assert!(is_forbidden_ip(ip(s)), "{s} should be forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_ipv6_is_allowed() {
|
||||
assert!(!is_forbidden_ip(ip("2606:4700:4700::1111"))); // cloudflare dns
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_blocks_unspecified_and_allows_public() {
|
||||
// 0.0.0.0 previously slipped through validate_url's literal-host check.
|
||||
assert!(validate_url(&Url::parse("http://0.0.0.0:8080/").unwrap(), false).is_err());
|
||||
assert!(validate_url(&Url::parse("http://127.0.0.1/").unwrap(), false).is_err());
|
||||
assert!(validate_url(&Url::parse("http://example.com/").unwrap(), false).is_ok());
|
||||
// The allow flag bypasses the guard (local-dev escape hatch).
|
||||
assert!(validate_url(&Url::parse("http://127.0.0.1/").unwrap(), true).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_blocks_hostname_that_resolves_to_loopback() {
|
||||
// localtest.me is a public DNS name that resolves to 127.0.0.1 — the
|
||||
// canonical DNS-rebinding test. The guard must reject it. If DNS is
|
||||
// unavailable the lookup itself errors (also Err), so the assertion
|
||||
// holds either way.
|
||||
let r = SsrfGuardResolver::new(false);
|
||||
let res = r.resolve(Name::from_str("localtest.me").unwrap()).await;
|
||||
assert!(res.is_err(), "localtest.me -> 127.0.0.1 must be blocked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_does_not_ssrf_block_public_host() {
|
||||
// A public host must not be SSRF-blocked. Tolerate a no-network sandbox
|
||||
// by only failing on an actual SSRF rejection, not a lookup failure.
|
||||
let r = SsrfGuardResolver::new(false);
|
||||
match r.resolve(Name::from_str("example.com").unwrap()).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => assert!(
|
||||
!e.to_string().contains("SSRF blocked"),
|
||||
"example.com wrongly SSRF-blocked: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ pub mod blocklist;
|
||||
pub mod wreq_client;
|
||||
|
||||
pub use client::{
|
||||
env_allows_private_network, ObscuraHttpClient, ObscuraNetError, RequestInfo, ResourceType,
|
||||
Response,
|
||||
env_allows_private_network, is_forbidden_ip, ObscuraHttpClient, ObscuraNetError, RequestInfo,
|
||||
ResourceType, Response, SsrfGuardResolver,
|
||||
};
|
||||
pub use cookies::{CookieInfo, CookieJar};
|
||||
pub use encoding::{
|
||||
|
||||
Reference in new issue
Block a user