#![cfg(feature = "fetch")]
use super::net_read::{MAX_FETCH_BYTES, read_capped_with_deadline};
fn ipv4_blocked(ip: &std::net::Ipv4Addr) -> bool {
ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_unspecified()
|| ip.is_broadcast()
|| is_cgnat(ip)
}
fn is_cgnat(ip: &std::net::Ipv4Addr) -> bool {
let o = ip.octets();
o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000
}
fn ipv6_blocked(ip: &std::net::Ipv6Addr) -> bool {
if ip.is_loopback() || ip.is_unspecified() {
return true;
}
if let Some(v4) = ip.to_ipv4() {
return ipv4_blocked(&v4);
}
let seg = ip.segments();
if seg[0] == 0x0064
&& seg[1] == 0xff9b
&& seg[2] == 0
&& seg[3] == 0
&& seg[4] == 0
&& seg[5] == 0
{
let v4 = std::net::Ipv4Addr::new(
(seg[6] >> 8) as u8,
(seg[6] & 0xff) as u8,
(seg[7] >> 8) as u8,
(seg[7] & 0xff) as u8,
);
return ipv4_blocked(&v4);
}
ip.is_unique_local() || ip.is_unicast_link_local()
}
fn socket_addr_blocked(sa: &std::net::SocketAddr) -> bool {
match sa.ip() {
std::net::IpAddr::V4(v4) => ipv4_blocked(&v4),
std::net::IpAddr::V6(v6) => ipv6_blocked(&v6),
}
}
#[derive(Debug)]
enum HostDecision {
Skipped,
Pinned {
host: String,
addr: std::net::SocketAddr,
},
}
fn check_url_host(url: &str) -> Result<HostDecision, String> {
let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid url {}: {}", url, e))?;
match parsed.scheme() {
"http" | "https" => {}
other => return Err(format!("scheme `{}` is not allowed for fetches", other)),
}
if std::env::var("MARKDOWN2PDF_ALLOW_PRIVATE_NETWORK").as_deref() == Ok("1") {
return Ok(HostDecision::Skipped);
}
let host = parsed
.host_str()
.ok_or_else(|| format!("url {} has no host", url))?;
if host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().ends_with(".localhost") {
return Err(format!("host `{}` is blocked (loopback)", host));
}
let bare_host = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
let port = parsed
.port_or_known_default()
.ok_or_else(|| format!("url {} has no resolvable port", url))?;
use std::net::ToSocketAddrs;
let addrs: Vec<std::net::SocketAddr> = (bare_host, port)
.to_socket_addrs()
.map_err(|e| format!("could not resolve host `{}`: {}", host, e))?
.collect();
if addrs.is_empty() {
return Err(format!("host `{}` resolved to no addresses", host));
}
for sa in &addrs {
if socket_addr_blocked(sa) {
return Err(format!(
"host `{}` resolves to blocked address `{}`",
host,
sa.ip()
));
}
}
Ok(HostDecision::Pinned {
host: bare_host.to_string(),
addr: addrs[0],
})
}
fn url_host_allowed(url: &str) -> Result<(), String> {
check_url_host(url).map(|_| ())
}
pub(crate) fn fetch_url(url: &str) -> Result<Vec<u8>, String> {
const TIMEOUT_SECS: u64 = 5;
const MAX_REDIRECTS: usize = 3;
let decision = check_url_host(url)?;
let mut builder = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.stop();
}
match url_host_allowed(attempt.url().as_str()) {
Ok(()) => attempt.follow(),
Err(e) => attempt.error(e),
}
}));
if let HostDecision::Pinned { host, addr } = &decision {
builder = builder.resolve(host, *addr);
}
let client = builder
.build()
.map_err(|e| format!("http client init: {}", e))?;
let resp = client.get(url).send().map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(TIMEOUT_SECS);
let buf = read_capped_with_deadline(resp, deadline)?;
if buf.len() as u64 > MAX_FETCH_BYTES {
return Err(format!(
"image at {} exceeds the {} byte cap",
url, MAX_FETCH_BYTES
));
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipv4_blocked_cases() {
for addr in [
"127.0.0.1",
"10.0.0.1",
"192.168.1.1",
"169.254.169.254",
"100.64.0.1",
"100.100.100.100",
"100.127.255.255",
] {
let ip: std::net::Ipv4Addr = addr.parse().unwrap();
assert!(ipv4_blocked(&ip), "{} should be blocked", addr);
}
}
#[test]
fn ipv4_cgnat_boundary_not_over_blocked() {
for addr in ["100.63.255.255", "100.128.0.0"] {
let ip: std::net::Ipv4Addr = addr.parse().unwrap();
assert!(!ipv4_blocked(&ip), "{} should not be blocked", addr);
}
}
#[test]
fn ipv4_allowed_case() {
let ip: std::net::Ipv4Addr = "93.184.216.34".parse().unwrap();
assert!(!ipv4_blocked(&ip), "public ipv4 should be allowed");
}
#[test]
fn ipv6_blocked_cases() {
for addr in ["::1", "fe80::1", "fc00::1"] {
let ip: std::net::Ipv6Addr = addr.parse().unwrap();
assert!(ipv6_blocked(&ip), "{} should be blocked", addr);
}
}
#[test]
fn ipv6_mapped_ipv4_uses_ipv4_rules() {
let ip: std::net::Ipv6Addr = "::ffff:169.254.169.254".parse().unwrap();
assert!(ipv6_blocked(&ip));
}
#[test]
fn ipv6_compatible_ipv4_embeddings_are_blocked() {
for addr in ["::127.0.0.1", "::10.0.0.5"] {
let ip: std::net::Ipv6Addr = addr.parse().unwrap();
assert!(ipv6_blocked(&ip), "{} should be blocked", addr);
}
}
#[test]
fn ipv6_nat64_embeddings_are_blocked() {
for addr in ["64:ff9b::127.0.0.1", "64:ff9b::169.254.169.254"] {
let ip: std::net::Ipv6Addr = addr.parse().unwrap();
assert!(ipv6_blocked(&ip), "{} should be blocked", addr);
}
}
#[test]
fn ipv6_nat64_embedding_of_public_ip_is_allowed() {
let ip: std::net::Ipv6Addr = "64:ff9b::5808:d822".parse().unwrap();
assert!(
!ipv6_blocked(&ip),
"NAT64-embedded public ip should be allowed"
);
}
#[test]
fn url_host_allowed_blocks_private_and_loopback() {
for url in [
"http://127.0.0.1:9/x.png",
"http://169.254.169.254/latest/meta-data/",
"http://localhost:9/x.png",
"http://[::1]:9/x.png",
"http://sub.localhost/x.png",
"http://100.64.0.1:9/x.png",
] {
assert!(url_host_allowed(url).is_err(), "{} should be blocked", url);
}
}
#[test]
fn url_host_allowed_blocks_non_http_schemes() {
assert!(url_host_allowed("file:///etc/hostname").is_err());
}
#[test]
fn url_host_allowed_permits_public_hosts() {
for url in ["http://93.184.216.34/x.png", "https://1.1.1.1/x.png"] {
assert!(url_host_allowed(url).is_ok(), "{} should be allowed", url);
}
}
#[test]
fn url_host_allowed_pins_resolved_address() {
match check_url_host("http://93.184.216.34:443/x.png") {
Ok(HostDecision::Pinned { host, addr }) => {
assert_eq!(host, "93.184.216.34");
assert_eq!(addr.ip().to_string(), "93.184.216.34");
assert_eq!(addr.port(), 443);
}
other => panic!("expected a pinned decision, got {other:?}"),
}
}
}