use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::Duration;
use anyhow::{bail, Context, Result};
use reqwest::header::{HeaderName, HeaderValue};
use reqwest::{Client, Response};
use url::{Host, Url};
pub const MAX_BODY_BYTES: usize = 8 * 1024 * 1024;
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
const READ_TIMEOUT: Duration = Duration::from_secs(15);
const MAX_REDIRECTS: usize = 5;
pub fn is_forbidden_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_forbidden_v4(v4),
IpAddr::V6(v6) => is_forbidden_v6(v6),
}
}
fn is_forbidden_v4(ip: &Ipv4Addr) -> bool {
ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() || matches!(ip.octets(), [0, ..])
|| matches!(ip.octets(), [100, b, ..] if (64..=127).contains(&b)) || matches!(ip.octets(), [192, 0, 0, _])
|| matches!(ip.octets(), [198, 18..=19, _, _])
}
fn is_forbidden_v6(ip: &Ipv6Addr) -> bool {
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
return true;
}
if let Some(v4) = ip.to_ipv4() {
return is_forbidden_v4(&v4);
}
let seg = ip.segments();
let link_local = (seg[0] & 0xffc0) == 0xfe80;
let ula = (seg[0] & 0xfe00) == 0xfc00;
link_local || ula
}
fn check_scheme(url: &Url) -> Result<()> {
match url.scheme() {
"http" | "https" => Ok(()),
other => bail!("refusing non-http(s) URL scheme {other:?}"),
}
}
async fn resolve_and_check(url: &Url) -> Result<SocketAddr> {
let host = url.host().context("URL has no host")?;
let port = url
.port_or_known_default()
.context("URL has no usable port")?;
match host {
Host::Ipv4(ip) => {
if is_forbidden_ip(&IpAddr::V4(ip)) {
bail!("refusing to fetch forbidden (internal) address {ip}");
}
Ok(SocketAddr::new(IpAddr::V4(ip), port))
}
Host::Ipv6(ip) => {
if is_forbidden_ip(&IpAddr::V6(ip)) {
bail!("refusing to fetch forbidden (internal) address {ip}");
}
Ok(SocketAddr::new(IpAddr::V6(ip), port))
}
Host::Domain(name) => {
let mut vetted: Option<SocketAddr> = None;
let addrs = tokio::net::lookup_host((name, port))
.await
.with_context(|| format!("resolving host {name:?}"))?;
for sa in addrs {
let ip = sa.ip();
if is_forbidden_ip(&ip) {
bail!("refusing to fetch {name:?}: resolves to forbidden address {ip}");
}
if vetted.is_none() {
vetted = Some(sa);
}
}
vetted.ok_or_else(|| anyhow::anyhow!("host {name:?} did not resolve to any address"))
}
}
}
fn pinned_client(host: &str, addr: SocketAddr) -> Result<Client> {
Client::builder()
.user_agent(crate::USER_AGENT)
.timeout(FETCH_TIMEOUT)
.read_timeout(READ_TIMEOUT)
.resolve(host, addr)
.redirect(reqwest::redirect::Policy::none())
.build()
.context("failed to build IP-pinned fetch client")
}
pub async fn guarded_get(
client: &Client,
url: &str,
extra_headers: &[(HeaderName, HeaderValue)],
) -> Result<Response> {
guarded_get_inner(client, url, extra_headers, true).await
}
pub async fn guarded_get_no_privacy(
client: &Client,
url: &str,
extra_headers: &[(HeaderName, HeaderValue)],
) -> Result<Response> {
guarded_get_inner(client, url, extra_headers, false).await
}
async fn guarded_get_inner(
client: &Client,
url: &str,
extra_headers: &[(HeaderName, HeaderValue)],
check_privacy: bool,
) -> Result<Response> {
let _ = client;
let mut current = Url::parse(url).with_context(|| format!("not a valid URL {url:?}"))?;
for _ in 0..=MAX_REDIRECTS {
check_scheme(¤t)?;
if check_privacy {
if let crate::feed::FeedPrivacy::Private(reason) =
crate::feed::classify_feed_privacy(current.as_str())
{
bail!("refusing to fetch private/paid feed URL (redirect target): {reason}");
}
}
let vetted = resolve_and_check(¤t).await?;
let host = current
.host_str()
.context("URL lost its host between hops")?
.to_string();
let hop_client = pinned_client(&host, vetted)?;
let mut req = hop_client.get(current.clone());
for (name, value) in extra_headers {
req = req.header(name.clone(), value.clone());
}
let resp = req
.send()
.await
.with_context(|| format!("fetching {current}"))?;
if resp.status().is_redirection() {
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.context("redirect response without a usable Location header")?;
current = current
.join(location)
.with_context(|| format!("resolving redirect Location {location:?}"))?;
continue;
}
return Ok(resp);
}
bail!("too many redirects (> {MAX_REDIRECTS}) while fetching {url:?}")
}
pub async fn read_capped(mut resp: Response) -> Result<Vec<u8>> {
let mut buf: Vec<u8> = Vec::with_capacity(16 * 1024);
while let Some(chunk) = resp.chunk().await.context("reading response body chunk")? {
if buf.len() + chunk.len() > MAX_BODY_BYTES {
bail!(
"response body exceeded the {} byte cap; aborting",
MAX_BODY_BYTES
);
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
pub async fn assert_public_target(url: &str) -> Result<()> {
let parsed = Url::parse(url).with_context(|| format!("not a valid URL {url:?}"))?;
check_scheme(&parsed)?;
resolve_and_check(&parsed).await?;
Ok(())
}
pub fn safe_link(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
match Url::parse(trimmed) {
Ok(u) if matches!(u.scheme(), "http" | "https") => Some(trimmed.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forbids_loopback_and_link_local_and_private_v4() {
for ip in [
"127.0.0.1",
"127.1.2.3",
"169.254.169.254", "10.0.0.5",
"172.16.9.9",
"192.168.1.1",
"0.0.0.0",
"255.255.255.255",
"100.64.0.1", ] {
let ip: IpAddr = ip.parse().unwrap();
assert!(is_forbidden_ip(&ip), "{ip} should be forbidden");
}
}
#[test]
fn allows_public_v4() {
for ip in ["1.1.1.1", "8.8.8.8", "93.184.216.34"] {
let ip: IpAddr = ip.parse().unwrap();
assert!(!is_forbidden_ip(&ip), "{ip} should be allowed");
}
}
#[test]
fn forbids_internal_v6() {
for ip in [
"::1",
"fe80::1",
"fc00::1",
"fd00::1",
"::ffff:127.0.0.1",
"::",
] {
let ip: IpAddr = ip.parse().unwrap();
assert!(is_forbidden_ip(&ip), "{ip} should be forbidden");
}
}
#[test]
fn allows_public_v6() {
let ip: IpAddr = "2606:4700:4700::1111".parse().unwrap();
assert!(!is_forbidden_ip(&ip));
}
#[tokio::test]
async fn resolve_and_check_rejects_ip_literals() {
for bad in [
"http://127.0.0.1/feed.xml",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]:80/x",
"http://192.168.0.1/",
] {
let u = Url::parse(bad).unwrap();
assert!(
resolve_and_check(&u).await.is_err(),
"{bad} should be rejected"
);
}
}
#[tokio::test]
async fn resolve_and_check_allows_public_ip_literal() {
let u = Url::parse("http://1.1.1.1/").unwrap();
let addr = resolve_and_check(&u).await.unwrap();
assert_eq!(addr, "1.1.1.1:80".parse::<SocketAddr>().unwrap());
}
#[tokio::test]
async fn resolve_and_check_pins_public_ipv6_literal() {
let u = Url::parse("http://[2606:4700:4700::1111]:443/").unwrap();
let addr = resolve_and_check(&u).await.unwrap();
assert_eq!(
addr,
"[2606:4700:4700::1111]:443".parse::<SocketAddr>().unwrap()
);
}
#[test]
fn pinned_client_builds_for_both_families() {
assert!(pinned_client("example.com", "93.184.216.34:80".parse().unwrap()).is_ok());
assert!(
pinned_client("example.com", "[2606:4700:4700::1111]:443".parse().unwrap()).is_ok()
);
}
#[test]
fn scheme_allowlist_rejects_non_http() {
assert!(check_scheme(&Url::parse("http://example.com/").unwrap()).is_ok());
assert!(check_scheme(&Url::parse("https://example.com/").unwrap()).is_ok());
assert!(check_scheme(&Url::parse("file:///etc/passwd").unwrap()).is_err());
assert!(check_scheme(&Url::parse("ftp://example.com/").unwrap()).is_err());
}
async fn serve_body(body: Vec<u8>) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut sock, _) = match listener.accept().await {
Ok(p) => p,
Err(_) => break,
};
let body = body.clone();
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = sock.write_all(header.as_bytes()).await;
let _ = sock.write_all(&body).await;
let _ = sock.flush().await;
});
}
});
format!("http://{addr}/")
}
#[tokio::test]
async fn read_capped_rejects_over_cap_body() {
let big = vec![b'x'; MAX_BODY_BYTES + 1];
let base = serve_body(big).await;
let client = reqwest::Client::builder().build().unwrap();
let resp = client.get(&base).send().await.unwrap();
let err = read_capped(resp).await.unwrap_err().to_string();
assert!(err.contains("exceeded"), "unexpected error: {err}");
}
#[tokio::test]
async fn read_capped_accepts_small_body() {
let base = serve_body(b"hello world".to_vec()).await;
let client = reqwest::Client::builder().build().unwrap();
let resp = client.get(&base).send().await.unwrap();
let body = read_capped(resp).await.unwrap();
assert_eq!(body, b"hello world");
}
#[tokio::test]
async fn guarded_get_refuses_private_redirect_target() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let resp = "HTTP/1.1 302 Found\r\nLocation: https://author.substack.com/feed/private/deadbeefcafe1234\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
}
});
let _ = addr; let client = Client::builder().build().unwrap();
let err = guarded_get(
&client,
"https://author.substack.com/feed/private/deadbeefcafe1234",
&[],
)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("private/paid feed"),
"expected privacy refusal, got: {err}"
);
}
#[test]
fn safe_link_allowlist() {
assert_eq!(
safe_link("https://ok.example/x").as_deref(),
Some("https://ok.example/x")
);
assert_eq!(
safe_link(" http://ok.example/ ").as_deref(),
Some("http://ok.example/")
);
assert_eq!(safe_link("javascript:alert(document.domain)"), None);
assert_eq!(safe_link("data:text/html,<script>alert(1)</script>"), None);
assert_eq!(safe_link(""), None);
assert_eq!(safe_link(" "), None);
assert_eq!(safe_link("/relative/path"), None);
}
}