use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
use std::time::Duration;
use ureq::http::Uri;
use crate::error::{Context, Error, Result};
use crate::{bail, err};
pub const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) tomesole/0.1";
pub const MAX_PAGE_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct NetPolicy {
pub allow_http: bool,
pub allow_private_hosts: bool,
pub connect_timeout: Duration,
pub request_timeout: Duration,
pub max_redirects: usize,
}
impl Default for NetPolicy {
fn default() -> Self {
Self {
allow_http: false,
allow_private_hosts: false,
connect_timeout: Duration::from_secs(10),
request_timeout: Duration::from_secs(30),
max_redirects: 8,
}
}
}
pub fn check_uri_shape(uri: &Uri, policy: &NetPolicy) -> Result<()> {
let scheme = uri.scheme_str().unwrap_or_default();
match scheme {
"https" => {}
"http" => {
if !policy.allow_http {
bail!("refusing cleartext http for {uri} (pass --allow-http to permit it)");
}
}
"" => bail!("URL has no scheme: {uri}"),
other => bail!("refusing non-http(s) URL scheme `{other}` in {uri}"),
}
let authority = uri
.authority()
.ok_or_else(|| err!("URL has no host component: {uri}"))?;
if authority.as_str().contains('@') {
bail!("refusing URL with embedded credentials: {uri}");
}
let host = host_of(uri)?;
if policy.allow_private_hosts {
return Ok(());
}
if let Ok(ip) = host.parse::<IpAddr>() {
if is_forbidden_ip(ip) {
bail!("refusing to connect to non-public address {ip}");
}
return Ok(());
}
if is_forbidden_hostname(&host) {
bail!("refusing to connect to local-only hostname `{host}`");
}
Ok(())
}
pub fn host_of(uri: &Uri) -> Result<String> {
let host = uri
.host()
.ok_or_else(|| err!("URL has no host component: {uri}"))?;
Ok(host
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase())
}
pub fn check_uri_resolves_publicly(uri: &Uri, policy: &NetPolicy) -> Result<()> {
check_uri_shape(uri, policy)?;
if policy.allow_private_hosts {
return Ok(());
}
let host = host_of(uri)?;
if host.parse::<IpAddr>().is_ok() {
return Ok(()); }
let port = uri.port_u16().unwrap_or(443);
let addrs = (host.as_str(), port)
.to_socket_addrs()
.with_context(|| format!("could not resolve host `{host}`"))?;
let mut saw_any = false;
for addr in addrs {
saw_any = true;
if is_forbidden_ip(addr.ip()) {
bail!(
"host `{host}` resolves to non-public address {} — refusing to connect",
addr.ip()
);
}
}
if !saw_any {
bail!("host `{host}` did not resolve to any address");
}
Ok(())
}
fn is_forbidden_hostname(name: &str) -> bool {
let name = name.trim_end_matches('.').to_ascii_lowercase();
if name == "localhost" {
return true;
}
const LOCAL_SUFFIXES: [&str; 6] = [
".localhost",
".local",
".internal",
".intranet",
".home.arpa",
".localdomain",
];
LOCAL_SUFFIXES.iter().any(|s| name.ends_with(s))
}
pub fn is_forbidden_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_forbidden_v4(v4),
IpAddr::V6(v6) => {
match v6.to_ipv4_mapped() {
Some(mapped) => is_forbidden_v4(mapped),
None => is_forbidden_v6(v6),
}
}
}
}
fn is_forbidden_v4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_documentation()
|| ip.is_unspecified()
|| ip.is_multicast()
|| a == 0 || (a == 100 && (64..=127).contains(&b)) || (a == 192 && b == 0 && c == 0) || (a == 198 && (b == 18 || b == 19)) || a >= 240 }
fn is_forbidden_v6(ip: Ipv6Addr) -> bool {
let s = ip.segments();
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 || (s[0] == 0x2001 && s[1] == 0x0db8) }
pub struct Http {
agent: ureq::Agent,
policy: NetPolicy,
}
pub struct Fetched {
pub status: u16,
pub body: ureq::Body,
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub content_disposition: Option<String>,
pub content_range: Option<String>,
}
impl Http {
pub fn new(policy: NetPolicy) -> Result<Self> {
let config = ureq::Agent::config_builder()
.user_agent(USER_AGENT)
.timeout_connect(Some(policy.connect_timeout))
.max_redirects(0)
.max_redirects_will_error(false)
.https_only(!policy.allow_http)
.build();
Ok(Self {
agent: ureq::Agent::new_with_config(config),
policy,
})
}
pub fn get(&self, uri: &Uri, range: Option<u64>) -> Result<Fetched> {
self.request(uri, range, None, true)
}
pub fn get_download(&self, uri: &Uri, range: Option<u64>) -> Result<Fetched> {
self.request(uri, range, None, false)
}
pub fn get_referred(&self, uri: &Uri, referer: &Uri) -> Result<Fetched> {
self.request(uri, None, Some(referer), true)
}
fn request(
&self,
uri: &Uri,
range: Option<u64>,
referer: Option<&Uri>,
bounded_body: bool,
) -> Result<Fetched> {
let mut current = uri.clone();
let mut hops = 0usize;
loop {
check_uri_resolves_publicly(¤t, &self.policy)?;
let config = self.agent.get(current.to_string()).config();
let mut req = if bounded_body {
config
.timeout_global(Some(self.policy.request_timeout))
.build()
} else {
config
.timeout_global(None)
.timeout_recv_response(None)
.timeout_recv_body(None)
.build()
};
if let Some(from) = range {
req = req.header("Range", format!("bytes={from}-"));
}
if let Some(referer) = referer
&& let (Ok(a), Ok(b)) = (host_of(referer), host_of(¤t))
&& a.eq_ignore_ascii_case(&b)
{
req = req.header("Referer", referer.to_string());
}
let resp = if range.is_some() {
req.header("Accept-Encoding", "identity").call()
} else {
req.call()
};
let resp = resp.map_err(|e| {
let host = host_of(¤t).unwrap_or_else(|_| current.to_string());
err!("{host}: {e}")
})?;
let status = resp.status().as_u16();
if (300..400).contains(&status) {
let location = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.ok_or_else(|| err!("{current} returned HTTP {status} with no Location"))?;
hops += 1;
if hops > self.policy.max_redirects {
bail!(
"too many redirects (>{}) starting at {uri}",
self.policy.max_redirects
);
}
let next = join_uri(¤t, &location)
.with_context(|| format!("bad redirect target `{location}` from {current}"))?;
current = next;
continue;
}
let headers = resp.headers();
let content_type = header_string(headers, "content-type");
let content_disposition = header_string(headers, "content-disposition");
let content_range = header_string(headers, "content-range");
let body = resp.into_body();
let content_length = body.content_length();
return Ok(Fetched {
status,
body,
content_type,
content_length,
content_disposition,
content_range,
});
}
}
pub fn get_text(&self, uri: &Uri) -> Result<String> {
let fetched = self.get(uri, None)?;
if !(200..300).contains(&fetched.status) {
bail!("{uri} returned HTTP {}", fetched.status);
}
fetched
.body
.into_with_config()
.limit(MAX_PAGE_BYTES)
.lossy_utf8(true)
.read_to_string()
.with_context(|| format!("could not read response body from {uri}"))
}
}
fn header_string(headers: &ureq::http::HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
}
pub fn join_uri(base: &Uri, reference: &str) -> Result<Uri> {
let reference = reference.trim();
if reference.is_empty() {
bail!("empty URL reference");
}
let reference = reference.split('#').next().unwrap_or(reference);
let scheme = base.scheme_str().unwrap_or("https");
let authority = base
.authority()
.ok_or_else(|| err!("base URL has no authority: {base}"))?
.as_str();
let joined = if reference.contains("://") {
reference.to_string()
} else if let Some(rest) = reference.strip_prefix("//") {
format!("{scheme}://{rest}")
} else if reference.starts_with('/') {
format!("{scheme}://{authority}{reference}")
} else {
let base_path = base.path();
let dir = match base_path.rfind('/') {
Some(i) => &base_path[..=i],
None => "/",
};
format!("{scheme}://{authority}{dir}{reference}")
};
let uri: Uri = normalize_dot_segments(&joined)
.parse()
.map_err(|e| Error::from(format!("invalid URL `{joined}`: {e}")))?;
Ok(uri)
}
fn normalize_dot_segments(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let after_scheme = scheme_end + 3;
let rest = &url[after_scheme..];
let path_start = rest.find('/').unwrap_or(rest.len());
let (authority, path_and_query) = rest.split_at(path_start);
if path_and_query.is_empty() {
return url.to_string();
}
let (path, query) = match path_and_query.find('?') {
Some(i) => path_and_query.split_at(i),
None => (path_and_query, ""),
};
let mut out: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
out.pop();
}
s => out.push(s),
}
}
let mut normalized = String::from(&url[..after_scheme]);
normalized.push_str(authority);
normalized.push('/');
normalized.push_str(&out.join("/"));
normalized.push_str(query);
normalized
}
pub fn encode_query_value(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 8);
for &byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
b' ' => out.push('+'),
_ => {
out.push('%');
out.push_str(&format!("{byte:02X}"));
}
}
}
out
}
pub fn with_retry<T, F>(attempts: usize, mut op: F) -> Result<T>
where
F: FnMut(usize) -> Result<T>,
{
let mut delay = Duration::from_millis(400);
let mut last_err = None;
let attempts = attempts.max(1);
for attempt in 0..attempts {
match op(attempt) {
Ok(v) => return Ok(v),
Err(e) => {
last_err = Some(e);
if attempt + 1 < attempts {
std::thread::sleep(delay);
delay = (delay * 2).min(Duration::from_secs(5));
}
}
}
}
Err(last_err.unwrap_or_else(|| err!("operation failed with no error recorded")))
}
#[cfg(test)]
mod tests {
use super::*;
fn uri(s: &str) -> Uri {
s.parse().unwrap()
}
#[test]
fn blocks_loopback_and_private_addresses() {
for s in [
"127.0.0.1",
"10.1.2.3",
"192.168.0.1",
"172.16.5.5",
"169.254.169.254", "100.64.0.1", "0.0.0.0",
"255.255.255.255",
"240.0.0.1",
"192.0.0.1",
"198.18.0.1",
] {
assert!(is_forbidden_ip(s.parse().unwrap()), "{s} should be blocked");
}
}
#[test]
fn blocks_ipv6_local_ranges() {
for s in [
"::1",
"fe80::1",
"fd00::1",
"::",
"ff02::1",
"::ffff:127.0.0.1",
"::ffff:10.0.0.1",
"2001:db8::1",
] {
assert!(is_forbidden_ip(s.parse().unwrap()), "{s} should be blocked");
}
}
#[test]
fn allows_ordinary_public_addresses() {
for s in ["1.1.1.1", "93.184.216.34", "2606:4700:4700::1111"] {
assert!(
!is_forbidden_ip(s.parse().unwrap()),
"{s} should be allowed"
);
}
}
#[test]
fn rejects_dangerous_uri_shapes() {
let p = NetPolicy::default();
for bad in [
"http://libgen.li/", "ftp://libgen.li/x",
"https://user:pw@libgen.li/", "https://localhost/x",
"https://router.local/x",
"https://foo.internal/x",
"https://127.0.0.1/x",
"https://[::1]/x",
"https://169.254.169.254/latest/meta-data/",
] {
assert!(
check_uri_shape(&uri(bad), &p).is_err(),
"{bad} should be rejected"
);
}
}
#[test]
fn accepts_normal_mirror_uris() {
let p = NetPolicy::default();
assert!(check_uri_shape(&uri("https://libgen.li/index.php?req=rust"), &p).is_ok());
}
#[test]
fn http_allowed_only_with_opt_in() {
let u = uri("http://libgen.li/");
assert!(check_uri_shape(&u, &NetPolicy::default()).is_err());
let relaxed = NetPolicy {
allow_http: true,
..NetPolicy::default()
};
assert!(check_uri_shape(&u, &relaxed).is_ok());
}
#[test]
fn joins_relative_links_like_a_browser() {
let base = uri("https://libgen.li/ads.php?md5=abc");
assert_eq!(
join_uri(&base, "get.php?md5=abc&key=K")
.unwrap()
.to_string(),
"https://libgen.li/get.php?md5=abc&key=K"
);
assert_eq!(
join_uri(&base, "/file.php?id=1").unwrap().to_string(),
"https://libgen.li/file.php?id=1"
);
assert_eq!(
join_uri(&base, "https://other.example/x")
.unwrap()
.to_string(),
"https://other.example/x"
);
assert_eq!(
join_uri(&base, "//cdn.example/x").unwrap().to_string(),
"https://cdn.example/x"
);
}
#[test]
fn join_resolves_dot_segments_and_drops_fragments() {
let base = uri("https://libgen.li/a/b/page.php");
assert_eq!(
join_uri(&base, "../c/get.php").unwrap().to_string(),
"https://libgen.li/a/c/get.php"
);
assert_eq!(
join_uri(&base, "get.php#frag").unwrap().to_string(),
"https://libgen.li/a/b/get.php"
);
}
#[test]
fn encodes_query_values() {
assert_eq!(encode_query_value("rust programming"), "rust+programming");
assert_eq!(encode_query_value("c++ & co"), "c%2B%2B+%26+co");
assert_eq!(encode_query_value("naïve"), "na%C3%AFve");
assert_eq!(encode_query_value("a-b_c.d~e"), "a-b_c.d~e");
}
#[test]
fn retry_gives_up_and_reports_last_error() {
let r: Result<()> = with_retry(2, |_| Err(err!("nope")));
assert_eq!(r.unwrap_err().to_string(), "nope");
}
#[test]
fn retry_succeeds_after_failure() {
let v = with_retry(3, |attempt| {
if attempt == 0 {
Err(err!("transient"))
} else {
Ok(attempt)
}
})
.unwrap();
assert_eq!(v, 1);
}
}