fix(render): apply the SSRF deny-set to the renderer's standalone HTTP loader

The render crate's compatibility loader (a pooled ureq agent behind
RenderResourceCache::default) fetched images and fonts with no network
policy at all. A page-owned runtime never uses it (it is cache-only and
fetches through obscura-net), but every standalone caller did: the
paint_file tool, library users rendering a bare DOM, and a JS runtime
with no page transport. Markup referencing http://127.0.0.1/,
http://169.254.169.254/ or a hostname that resolves to a private address
made the renderer connect to it.

Move is_forbidden_ip and env_allows_private_network into a new
zero-dependency obscura-ssrf crate so obscura-net and obscura-render
enforce one deny-set (obscura-net re-exports both, so its API and the
wreq transport are unchanged). Install a ureq resolver on the render
agent that rejects any resolved address in the deny-set unless
OBSCURA_ALLOW_PRIVATE_NETWORK is set, which covers literal addresses,
DNS rebinding, and every redirect hop. A resolver refusal surfaces as a
DNS transport error; the retry loop now returns immediately on those
instead of sleeping through 600ms of backoff for a request it will not
make.
This commit is contained in:
Andrey Mnatsakanov
2026-09-24 15:50:49 +02:00
parent d1f55b8221
commit e5904a6ee2
8 changed files with 247 additions and 111 deletions

6
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -7676,6 +7676,12 @@ fn http_get_bytes(url: &str) -> Option<Vec<u8>> {
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<Vec<std::net::SocketAddr>> {
use std::net::ToSocketAddrs;
let addrs: Vec<std::net::SocketAddr> = 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<std::sync::atomic::AtomicUsize>) {
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));

View File

@@ -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]

View File

@@ -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)
}
}
}