146 lines
4.8 KiB
Rust
146 lines
4.8 KiB
Rust
use std::net::IpAddr;
|
|
|
|
use anyhow::bail;
|
|
use reqwest::{redirect::Policy, Client, Response, Url};
|
|
use tokio::net::lookup_host;
|
|
|
|
use crate::error::AppError;
|
|
|
|
// Outbound requests for feed/article URLs are driven by user input (feed URLs,
|
|
// "read" links). Without these checks a user could point the server at
|
|
// internal services or cloud metadata endpoints (e.g. http://169.254.169.254/)
|
|
// and have it fetch them on their behalf (SSRF).
|
|
pub async fn safe_fetch(url: &str) -> Result<Response, AppError> {
|
|
safe_fetch_inner(url).await.map_err(AppError::from)
|
|
}
|
|
|
|
// Redirects are validated and followed manually (rather than via
|
|
// `redirect::Policy::default()`) so each hop's resolved address is checked
|
|
// against `is_globally_routable` before it's fetched — otherwise an allowed
|
|
// host could redirect to an internal address and bypass the checks below.
|
|
const MAX_REDIRECTS: u8 = 5;
|
|
|
|
async fn safe_fetch_inner(url: &str) -> anyhow::Result<Response> {
|
|
let client = Client::builder().redirect(Policy::none()).build()?;
|
|
|
|
let mut current = Url::parse(url)?;
|
|
for _ in 0..=MAX_REDIRECTS {
|
|
check_url_is_safe(¤t).await?;
|
|
|
|
let response = client.get(current.clone()).send().await?;
|
|
|
|
if response.status().is_redirection() {
|
|
let location = response
|
|
.headers()
|
|
.get(reqwest::header::LOCATION)
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("redirect response from {} has no Location header", current)
|
|
})?
|
|
.to_str()?;
|
|
current = current.join(location)?;
|
|
continue;
|
|
}
|
|
|
|
return Ok(response);
|
|
}
|
|
|
|
bail!("refusing to fetch {}: too many redirects", url);
|
|
}
|
|
|
|
async fn check_url_is_safe(url: &Url) -> anyhow::Result<()> {
|
|
if url.scheme() != "http" && url.scheme() != "https" {
|
|
bail!("refusing to fetch {}: unsupported URL scheme", url);
|
|
}
|
|
|
|
let host = url
|
|
.host_str()
|
|
.ok_or_else(|| anyhow::anyhow!("refusing to fetch {}: URL has no host", url))?;
|
|
let port = url.port_or_known_default().unwrap_or(80);
|
|
|
|
let mut resolved_any = false;
|
|
for addr in lookup_host((host, port)).await? {
|
|
resolved_any = true;
|
|
if !is_globally_routable(addr.ip()) {
|
|
bail!(
|
|
"refusing to fetch {}: resolves to non-public address {}",
|
|
url,
|
|
addr.ip()
|
|
);
|
|
}
|
|
}
|
|
if !resolved_any {
|
|
bail!("refusing to fetch {}: host did not resolve to any address", url);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn is_globally_routable(ip: IpAddr) -> bool {
|
|
match ip {
|
|
IpAddr::V4(v4) => {
|
|
!(v4.is_private()
|
|
|| v4.is_loopback()
|
|
|| v4.is_link_local()
|
|
|| v4.is_unspecified()
|
|
|| v4.is_broadcast()
|
|
|| v4.is_multicast())
|
|
}
|
|
IpAddr::V6(v6) => {
|
|
if let Some(v4) = v6.to_ipv4_mapped() {
|
|
return is_globally_routable(IpAddr::V4(v4));
|
|
}
|
|
|
|
let segments = v6.segments();
|
|
let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; // fc00::/7
|
|
let is_unicast_link_local = (segments[0] & 0xffc0) == 0xfe80; // fe80::/10
|
|
|
|
!(v6.is_loopback()
|
|
|| v6.is_unspecified()
|
|
|| v6.is_multicast()
|
|
|| is_unique_local
|
|
|| is_unicast_link_local)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rejects_loopback_and_private_addresses() {
|
|
assert!(!is_globally_routable("127.0.0.1".parse().unwrap()));
|
|
assert!(!is_globally_routable("10.0.0.5".parse().unwrap()));
|
|
assert!(!is_globally_routable("192.168.1.1".parse().unwrap()));
|
|
assert!(!is_globally_routable("169.254.169.254".parse().unwrap()));
|
|
assert!(!is_globally_routable("::1".parse().unwrap()));
|
|
assert!(!is_globally_routable("fc00::1".parse().unwrap()));
|
|
assert!(!is_globally_routable("fe80::1".parse().unwrap()));
|
|
assert!(!is_globally_routable("::ffff:127.0.0.1".parse().unwrap()));
|
|
}
|
|
|
|
#[test]
|
|
fn allows_public_addresses() {
|
|
assert!(is_globally_routable("93.184.216.34".parse().unwrap()));
|
|
assert!(is_globally_routable("2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()));
|
|
}
|
|
|
|
#[actix_web::test]
|
|
async fn rejects_unsupported_schemes() {
|
|
let result = safe_fetch("ftp://example.test/file").await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[actix_web::test]
|
|
async fn rejects_loopback_urls() {
|
|
let result = safe_fetch("http://127.0.0.1:8001/").await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[actix_web::test]
|
|
async fn rejects_link_local_metadata_url() {
|
|
let result = safe_fetch("http://169.254.169.254/latest/meta-data/").await;
|
|
assert!(result.is_err());
|
|
}
|
|
}
|