diff --git a/Cargo.lock b/Cargo.lock index 797985c..2f32498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2080,6 +2080,7 @@ dependencies = [ "async-trait", "encoding_rs", "futures-util", + "obscura-ssrf", "psl", "rcgen", "reqwest", @@ -2107,6 +2108,7 @@ dependencies = [ "image", "log", "obscura-dom", + "obscura-ssrf", "resvg", "swash", "taffy", @@ -2117,6 +2119,10 @@ dependencies = [ "wuff", ] +[[package]] +name = "obscura-ssrf" +version = "0.1.0" + [[package]] name = "once_cell" version = "1.21.4" diff --git a/Cargo.toml b/Cargo.toml index 8efd2a6..df15f6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/obscura-dom", + "crates/obscura-ssrf", "crates/obscura-net", "crates/obscura-browser", "crates/obscura-cdp", @@ -20,6 +21,7 @@ repository = "https://github.com/h4ckf0r0day/obscura" [workspace.dependencies] obscura-dom = { path = "crates/obscura-dom" } +obscura-ssrf = { path = "crates/obscura-ssrf" } obscura-net = { path = "crates/obscura-net" } obscura-browser = { path = "crates/obscura-browser" } obscura-cdp = { path = "crates/obscura-cdp" } diff --git a/crates/obscura-net/Cargo.toml b/crates/obscura-net/Cargo.toml index 070a4fd..3bc5119 100644 --- a/crates/obscura-net/Cargo.toml +++ b/crates/obscura-net/Cargo.toml @@ -10,6 +10,7 @@ default = [] stealth = ["wreq", "wreq-util", "futures-util"] [dependencies] +obscura-ssrf = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } url = { workspace = true } diff --git a/crates/obscura-net/src/client.rs b/crates/obscura-net/src/client.rs index ff84f16..c9fc15f 100644 --- a/crates/obscura-net/src/client.rs +++ b/crates/obscura-net/src/client.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, OnceLock}; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{Duration, Instant}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::{IpAddr, SocketAddr}; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT}; @@ -592,115 +592,7 @@ impl Default for CallbackRegistry { } } -/// Process-wide opt-in via env var. Older flow that issue #4 introduced. The -/// new `--allow-private-network` CLI flag (issue #33) sets a per-client field -/// that is OR'd with this so existing scripts and Docker setups that pin the -/// env var keep working unchanged. -pub fn env_allows_private_network() -> bool { - matches!( - std::env::var("OBSCURA_ALLOW_PRIVATE_NETWORK") - .ok() - .as_deref() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("1") | Some("true") | Some("yes") | Some("on") - ) -} - -/// 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) => { - let o = v4.octets(); - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_broadcast() - || v4.is_documentation() - || v4.is_unspecified() - || v4.is_multicast() - || o[0] == 0 - // std's is_private() covers only RFC1918, so add the IANA - // special-purpose ranges that also host internal services and - // are common SSRF targets: - // 100.64.0.0/10 CGNAT / RFC6598 — cloud metadata (e.g. - // Alibaba 100.100.100.200) lives here. - // 198.18.0.0/15 benchmarking / RFC2544. - // 192.88.99.0/24 6to4 relay anycast / RFC7526. - || (o[0] == 100 && (64..=127).contains(&o[1])) - || (o[0] == 198 && (o[1] == 18 || o[1] == 19)) - || (o[0] == 192 && o[1] == 88 && o[2] == 99) - // Most of 192.0.0.0/24 is special-purpose and not globally - // reachable. Keep the two globally reachable PCP anycast - // assignments usable rather than blocking the entire /24. - || (o[0] == 192 - && o[1] == 0 - && o[2] == 0 - && o[3] != 9 - && o[3] != 10) - // 240.0.0.0/4 is reserved (255.255.255.255 was already - // covered by is_broadcast()). - || o[0] >= 240 - } - IpAddr::V6(v6) => { - if v6.is_loopback() - || v6.is_unspecified() - || v6.is_unique_local() - || v6.is_unicast_link_local() - || v6.is_multicast() - { - 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)); - } - - let s = v6.segments(); - // IPv4/IPv6 translation prefix (RFC 6052). Only /96 has a fixed - // embedded-address position; the local-use /48 is therefore - // blocked outright below. - if s[0] == 0x64 - && s[1] == 0xff9b - && s[2] == 0 - && s[3] == 0 - && s[4] == 0 - && s[5] == 0 - { - return is_forbidden_ip(IpAddr::V4(Ipv4Addr::new( - (s[6] >> 8) as u8, - s[6] as u8, - (s[7] >> 8) as u8, - s[7] as u8, - ))); - } - // 6to4 carries its IPv4 endpoint in bits 16..48. - if s[0] == 0x2002 { - return is_forbidden_ip(IpAddr::V4(Ipv4Addr::new( - (s[1] >> 8) as u8, - s[1] as u8, - (s[2] >> 8) as u8, - s[2] as u8, - ))); - } - - // Discard-only, local-use NAT64, and documentation prefixes. - (s[0] == 0x100 && s[1] == 0 && s[2] == 0 && s[3] == 0) - || (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1) - || (s[0] == 0x2001 && s[1] == 0x0db8) - || (s[0] == 0x3fff && s[1] & 0xf000 == 0) - } - } -} +pub use obscura_ssrf::{env_allows_private_network, is_forbidden_ip}; /// 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 diff --git a/crates/obscura-render/Cargo.toml b/crates/obscura-render/Cargo.toml index 4c8e19e..db0f600 100644 --- a/crates/obscura-render/Cargo.toml +++ b/crates/obscura-render/Cargo.toml @@ -11,11 +11,12 @@ default = [] # Rasterize the laid-out tree to a pixmap (tiny-skia) with text (cosmic-text # shaping + line breaking, ab_glyph fallback) and images. # Off by default; the layout core (getBoundingClientRect etc.) works without it. -paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:swash", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"] +paint = ["dep:obscura-ssrf", "dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:swash", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"] [dependencies] taffy = { workspace = true } obscura-dom = { workspace = true } +obscura-ssrf = { workspace = true, optional = true } tiny-skia = { workspace = true, optional = true } ab_glyph = { workspace = true, optional = true } # Real inline text layout: HarfBuzz-class shaping (rustybuzz) + UAX#14 line diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index a192d87..89ab5bc 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -7676,6 +7676,12 @@ fn http_get_bytes(url: &str) -> Option> { std::thread::sleep(backoff); backoff *= 2; } + // A resolution failure is not transient at this timescale, and it + // is also how the SSRF guard below refuses a host: never sleep + // through the backoff for a request that will not be made. + Err(ureq::Error::Transport(err)) if err.kind() == ureq::ErrorKind::Dns => { + return None; + } Err(ureq::Error::Transport(_)) if attempt < 2 => { std::thread::sleep(backoff); backoff *= 2; @@ -7703,10 +7709,44 @@ fn image_agent() -> &'static ureq::Agent { // cnbc, techcrunch, arstechnica), so the images Chrome loads came // back blank; a real browser UA loads the same bytes Chrome does. .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") + .resolver(SsrfGuardResolver) .build() }) } +/// The renderer's copy of the SSRF gate every page transport applies. This +/// loader only runs for standalone callers (the `paint_file` tool, library +/// users rendering a bare DOM, a JS runtime with no page transport); a +/// page-owned runtime is cache-only and fetches through `obscura-net`, which +/// has its own resolver. Checking at resolution time covers literal +/// addresses, names that resolve to a private address (DNS rebinding), and +/// every redirect hop, since ureq resolves each connection through here. +/// `OBSCURA_ALLOW_PRIVATE_NETWORK` lifts the restriction, as it does for the +/// transports; the renderer has no per-context flag to consult. +struct SsrfGuardResolver; + +impl ureq::Resolver for SsrfGuardResolver { + fn resolve(&self, netloc: &str) -> std::io::Result> { + use std::net::ToSocketAddrs; + let addrs: Vec = netloc.to_socket_addrs()?.collect(); + if !obscura_ssrf::env_allows_private_network() { + if let Some(bad) = addrs + .iter() + .find(|addr| obscura_ssrf::is_forbidden_ip(addr.ip())) + { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!( + "SSRF blocked: '{netloc}' resolves to forbidden address {}", + bad.ip() + ), + )); + } + } + Ok(addrs) + } +} + /// Decode a percent-escaped data: URI payload (`%23` -> `#`, etc). Bytes that /// are not part of a `%XX` escape pass through unchanged, which is exactly /// right for the inline-SVG case: only the characters that would otherwise be @@ -12088,6 +12128,71 @@ mod tests { ); } + /// Loopback HTTP/1.0 server that counts connections and answers every + /// request with `body`. The count is the evidence: a refused fetch must + /// never even open the socket. + fn loopback_http_server( + body: &'static [u8], + ) -> (u16, std::sync::Arc) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = hits.clone(); + std::thread::spawn(move || { + use std::io::{Read, Write}; + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let mut request = [0u8; 1024]; + let _ = stream.read(&mut request); + let _ = write!( + stream, + "HTTP/1.0 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(body); + } + }); + (port, hits) + } + + #[test] + fn default_loader_refuses_private_network_hosts() { + std::env::remove_var("OBSCURA_ALLOW_PRIVATE_NETWORK"); + let (port, hits) = loopback_http_server(b"png-bytes"); + let mut cache = RenderResourceCache::default(); + // A literal loopback address and a name that resolves to one: the + // second must be caught at DNS resolution, the same way the page + // transports close DNS rebinding. + for url in [ + format!("http://127.0.0.1:{port}/a.png"), + format!("http://localhost:{port}/b.png"), + ] { + assert!( + fetch_bytes(&url, None, &mut cache).is_none(), + "{url} must be refused by the standalone loader" + ); + } + assert_eq!( + hits.load(std::sync::atomic::Ordering::SeqCst), + 0, + "a forbidden host must never be connected to" + ); + } + + #[test] + fn default_loader_honors_the_private_network_opt_in() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let (port, hits) = loopback_http_server(b"png-bytes"); + let mut cache = RenderResourceCache::default(); + let url = format!("http://127.0.0.1:{port}/a.png"); + assert_eq!( + fetch_bytes(&url, None, &mut cache).as_deref(), + Some(b"png-bytes".as_slice()) + ); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1); + } + #[test] fn cache_only_mode_does_not_load_or_negative_cache_unknown_urls() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); diff --git a/crates/obscura-ssrf/Cargo.toml b/crates/obscura-ssrf/Cargo.toml new file mode 100644 index 0000000..46175fd --- /dev/null +++ b/crates/obscura-ssrf/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "obscura-ssrf" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared SSRF deny-set and private-network opt-in for Obscura's network transports and renderer." + +[dependencies] diff --git a/crates/obscura-ssrf/src/lib.rs b/crates/obscura-ssrf/src/lib.rs new file mode 100644 index 0000000..9da528d --- /dev/null +++ b/crates/obscura-ssrf/src/lib.rs @@ -0,0 +1,120 @@ +//! The SSRF policy every Obscura transport shares: which IP addresses an +//! outbound request may never reach, and the process-wide env opt-in that +//! lifts the restriction for local testing. +//! +//! This crate has no dependencies on purpose. `obscura-net` (reqwest and wreq +//! transports) and `obscura-render` (the synchronous compatibility loader for +//! images and fonts) both enforce the same deny-set from here, so the checks +//! can never disagree. + +use std::net::{IpAddr, Ipv4Addr}; + +/// Process-wide opt-in via env var. Older flow that issue #4 introduced. The +/// new `--allow-private-network` CLI flag (issue #33) sets a per-client field +/// that is OR'd with this so existing scripts and Docker setups that pin the +/// env var keep working unchanged. +pub fn env_allows_private_network() -> bool { + matches!( + std::env::var("OBSCURA_ALLOW_PRIVATE_NETWORK") + .ok() + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1") | Some("true") | Some("yes") | Some("on") + ) +} + +/// 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) => { + let o = v4.octets(); + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_documentation() + || v4.is_unspecified() + || v4.is_multicast() + || o[0] == 0 + // std's is_private() covers only RFC1918, so add the IANA + // special-purpose ranges that also host internal services and + // are common SSRF targets: + // 100.64.0.0/10 CGNAT / RFC6598 — cloud metadata (e.g. + // Alibaba 100.100.100.200) lives here. + // 198.18.0.0/15 benchmarking / RFC2544. + // 192.88.99.0/24 6to4 relay anycast / RFC7526. + || (o[0] == 100 && (64..=127).contains(&o[1])) + || (o[0] == 198 && (o[1] == 18 || o[1] == 19)) + || (o[0] == 192 && o[1] == 88 && o[2] == 99) + // Most of 192.0.0.0/24 is special-purpose and not globally + // reachable. Keep the two globally reachable PCP anycast + // assignments usable rather than blocking the entire /24. + || (o[0] == 192 + && o[1] == 0 + && o[2] == 0 + && o[3] != 9 + && o[3] != 10) + // 240.0.0.0/4 is reserved (255.255.255.255 was already + // covered by is_broadcast()). + || o[0] >= 240 + } + IpAddr::V6(v6) => { + if v6.is_loopback() + || v6.is_unspecified() + || v6.is_unique_local() + || v6.is_unicast_link_local() + || v6.is_multicast() + { + 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)); + } + + let s = v6.segments(); + // IPv4/IPv6 translation prefix (RFC 6052). Only /96 has a fixed + // embedded-address position; the local-use /48 is therefore + // blocked outright below. + if s[0] == 0x64 + && s[1] == 0xff9b + && s[2] == 0 + && s[3] == 0 + && s[4] == 0 + && s[5] == 0 + { + return is_forbidden_ip(IpAddr::V4(Ipv4Addr::new( + (s[6] >> 8) as u8, + s[6] as u8, + (s[7] >> 8) as u8, + s[7] as u8, + ))); + } + // 6to4 carries its IPv4 endpoint in bits 16..48. + if s[0] == 0x2002 { + return is_forbidden_ip(IpAddr::V4(Ipv4Addr::new( + (s[1] >> 8) as u8, + s[1] as u8, + (s[2] >> 8) as u8, + s[2] as u8, + ))); + } + + // Discard-only, local-use NAT64, and documentation prefixes. + (s[0] == 0x100 && s[1] == 0 && s[2] == 0 && s[3] == 0) + || (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1) + || (s[0] == 0x2001 && s[1] == 0x0db8) + || (s[0] == 0x3fff && s[1] & 0xf000 == 0) + } + } +}