//! Defensive helpers for the image-download paths. //! //! Two threats this module addresses: //! //! - **SSRF**: a scraped chapter or manga page can embed an absolute //! ``. The crawler runs inside the //! backend container with intra-compose access to `postgres:5432` //! and possibly other internal services; without a host check the //! crawler would happily probe them. [`is_safe_url`] rejects //! anything whose host isn't on the operator-configured allowlist, //! plus any IP literal in RFC1918 / loopback / link-local / unique- //! local space (including IPv4-mapped IPv6 like `::ffff:127.0.0.1`) //! as a second defence for the case where an allowlisted hostname's //! DNS happens to resolve to a literal private address. //! //! **DNS rebinding is not covered.** A hostname like `cdn.allowed.com` //! that *resolves* to `127.0.0.1` via hostile DNS bypasses the IP //! check entirely — `is_safe_url` only inspects URL strings, not //! resolved IPs. Mitigating that requires a custom reqwest resolver //! that filters IPs after DNS, which would mean rebuilding reqwest's //! connector. The allowlist + good operator DNS hygiene is the //! realistic mitigation today. //! //! - **Unbounded download**: `Response::bytes().await` reads the full //! body before returning. A malicious source serving a 10 GiB image //! would fill memory and then disk. [`accumulate_capped`] streams //! the body chunk-by-chunk into a [`bytes::BytesMut`] and bails as //! soon as the running total exceeds the cap. //! //! Both helpers are pure-data: the SSRF check is keyed off a parsed //! URL string, and the byte accumulator is keyed off a generic stream. //! Easy to unit-test without a live network or browser. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use anyhow::{bail, Context}; use bytes::BytesMut; use futures_util::StreamExt; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use reqwest::Url; /// Default per-image download cap. A page image is generally <2 MiB; /// 32 MiB leaves headroom for high-resolution covers while still /// stopping a misbehaving CDN dead. Override via `CRAWLER_MAX_IMAGE_BYTES`. pub const DEFAULT_MAX_IMAGE_BYTES: usize = 32 * 1024 * 1024; /// Hosts that are always allowed in addition to the operator's /// configured allowlist. None by default — keeping the surface area /// minimal so the only way a URL gets through is if it matches an /// explicit catalog/CDN entry. /// /// `allow_any` flips the host check off entirely (private-IP and /// scheme checks still apply). It exists for operators whose sources /// shard images across numbered CDN subdomains (`cdn1`, `cdn2`, …) /// where enumerating each host upfront is impractical. Off by default. #[derive(Clone, Debug, Default)] pub struct DownloadAllowlist { hosts: Vec, allow_any: bool, } impl DownloadAllowlist { pub fn new() -> Self { Self { hosts: Vec::new(), allow_any: false, } } /// Bypass the host allowlist. Scheme, localhost, and private-IP /// checks in [`is_safe_url`] continue to apply — this only opens /// up public hosts that weren't pre-enumerated. pub fn allow_any() -> Self { Self { hosts: Vec::new(), allow_any: true, } } /// Add a host (case-insensitive match). Sub-domains are *not* /// implied: pass `cdn.example.com` and `example.com` separately /// if both should be reachable. pub fn allow(mut self, host: impl Into) -> Self { let h = host.into().to_ascii_lowercase(); if !h.is_empty() && !self.hosts.iter().any(|existing| existing == &h) { self.hosts.push(h); } self } pub fn is_empty(&self) -> bool { self.hosts.is_empty() } /// The explicitly-allowed hosts (lowercased). Empty when `allow_any`. pub fn hosts(&self) -> &[String] { &self.hosts } /// Whether the host check is bypassed entirely (`CRAWLER_ALLOW_ANY_HOST`). pub fn is_allow_any(&self) -> bool { self.allow_any } pub fn contains(&self, host: &str) -> bool { if self.allow_any { return true; } let lower = host.to_ascii_lowercase(); self.hosts.iter().any(|h| h == &lower) } } /// Verify a URL is safe for the crawler to fetch. /// /// Rejects: /// - non-http(s) schemes (file://, gopher://, …), /// - any IP literal in private / loopback / link-local / unique-local /// space (defense in depth — a DNS allowlist alone wouldn't cover an /// attacker that places an entry like `cdn.evil` pointing at /// `192.168.1.1`), /// - the literal hostname `localhost`, /// - hosts that aren't on the supplied allowlist. /// /// An empty allowlist rejects everything (the conservative default — /// callers must explicitly allow the catalog and CDN hosts). pub fn is_safe_url(raw_url: &str, allow: &DownloadAllowlist) -> Result<(), UrlSafetyError> { let url = ensure_public_target_inner(raw_url)?; let lower_host = url .host_str() .expect("host validated by ensure_public_target_inner") .to_ascii_lowercase(); if !allow.contains(&lower_host) { return Err(UrlSafetyError::HostNotAllowed(lower_host)); } Ok(()) } /// Validate that an admin-supplied URL points at a publicly-routable target: /// scheme is http or https, host is present, host isn't `localhost`, and (if /// the host is an IP literal) it isn't loopback/private/link-local/CGNAT/etc. /// /// Used to validate `endpoint`-style admin settings (crawler `start_url`, /// analysis `endpoint`) where there is no per-deployment allowlist to consult, /// but where a hostile or careless admin value (e.g. `http://169.254.169.254/`, /// `http://127.0.0.1:5432/`) would let the worker pivot inside the deployment. /// /// Note: DNS hostnames (`mangalord-vision`, `vision.internal.corp`) pass — the /// check is on *literal* IP private-range strings only, so the documented /// docker-internal vision endpoint keeps working. pub fn ensure_public_target(raw_url: &str) -> Result<(), UrlSafetyError> { ensure_public_target_inner(raw_url).map(|_| ()) } fn ensure_public_target_inner(raw_url: &str) -> Result { let url = Url::parse(raw_url).map_err(|_| UrlSafetyError::Unparseable)?; let scheme = url.scheme(); if scheme != "http" && scheme != "https" { return Err(UrlSafetyError::BadScheme(scheme.to_string())); } let host = url.host_str().ok_or(UrlSafetyError::NoHost)?; let lower_host = host.to_ascii_lowercase(); if lower_host == "localhost" { return Err(UrlSafetyError::Loopback); } // Reject IP literals in private/loopback ranges regardless of the // allowlist — if someone puts an IP literal on the allowlist they // almost certainly didn't mean a private range. // reqwest::Url normalises IPv6 literals as `[::1]` (brackets // included) in `host_str()`. Strip the brackets before parsing. let ip_candidate = lower_host .strip_prefix('[') .and_then(|s| s.strip_suffix(']')) .unwrap_or(&lower_host); if let Ok(ip) = ip_candidate.parse::() { if is_private_ip(&ip) { return Err(UrlSafetyError::PrivateIp(ip)); } } Ok(url) } pub(crate) fn is_private_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(v4) => { v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() // CGNAT 100.64.0.0/10 || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // 169.254/16 link-local already covered, but 0.0.0.0/8 is special-use || v4.octets()[0] == 0 } IpAddr::V6(v6) => { // Any IPv6 form that *embeds* an IPv4 address (mapped // `::ffff:0:0/96`, compatible `::/96`, NAT64 `64:ff9b::/96`, // 6to4 `2002::/16`) is unwrapped and re-checked as its IPv4 — // otherwise `::127.0.0.1` / `2002:7f00:1::` / `64:ff9b::7f00:1` // would smuggle an internal IPv4 past the check (the audit's // IPv6-embedding gap). `Ipv6Addr::is_loopback()` only matches // `::1` exactly, so these embeddings need explicit handling. if let Some(v4) = embedded_ipv4(v6) { return is_private_ip(&IpAddr::V4(v4)); } v6.is_loopback() || v6.is_unspecified() // fc00::/7 unique-local || (v6.segments()[0] & 0xfe00) == 0xfc00 // fe80::/10 link-local || (v6.segments()[0] & 0xffc0) == 0xfe80 } } } /// Extract the IPv4 address embedded in an IPv6 literal, for every /// transitional encoding that can carry one: IPv4-mapped (`::ffff:0:0/96`), /// IPv4-compatible (`::/96`, deprecated but still routable via some stacks), /// NAT64 (`64:ff9b::/96`), and 6to4 (`2002::/16`). Returns `None` for a /// native IPv6 address. Callers recurse into [`is_private_ip`] on the result /// so a private IPv4 can't hide inside an IPv6 literal. fn embedded_ipv4(v6: &Ipv6Addr) -> Option { let seg = v6.segments(); let low32 = |a: u16, b: u16| Ipv4Addr::new((a >> 8) as u8, (a & 0xff) as u8, (b >> 8) as u8, (b & 0xff) as u8); // ::ffff:0:0/96 (mapped) and ::/96 (compatible) — top 96 bits zero, // except mapped which has 0xffff at seg[5]. to_ipv4() covers both. if seg[0..5] == [0, 0, 0, 0, 0] && (seg[5] == 0 || seg[5] == 0xffff) { return Some(low32(seg[6], seg[7])); } // NAT64 64:ff9b::/96 if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0] { return Some(low32(seg[6], seg[7])); } // 6to4 2002::/16 — embedded IPv4 is bits 16..48 (seg[1], seg[2]). if seg[0] == 0x2002 { return Some(low32(seg[1], seg[2])); } None } /// A `reqwest::dns::Resolve` that performs normal system resolution, then /// drops any resolved address in a private / loopback / link-local / metadata /// range. Installed on every crawler + analysis reqwest client so a hostname /// that resolves to an internal IP (DNS rebinding: an attacker-owned domain /// with an `A` record for `169.254.169.254` or `10.x`) can never be connected /// to — closing the TOCTOU gap that the string-only `ensure_public_target` /// check leaves open. Fires per connection, so it also guards redirect hops. #[derive(Debug, Default)] pub struct SafeResolver; /// Partition resolved addresses into the public ones, rejecting when nothing /// survives. Split out from the async `resolve` so the security-critical /// filter is unit-testable without real DNS. fn retain_public_addrs( host: &str, addrs: impl Iterator, ) -> Result, BlockedResolution> { let public: Vec = addrs.filter(|a| !is_private_ip(&a.ip())).collect(); if public.is_empty() { return Err(BlockedResolution(host.to_string())); } Ok(public) } #[derive(Debug, thiserror::Error)] #[error("host {0} resolved only to private/blocked addresses")] struct BlockedResolution(String); impl Resolve for SafeResolver { fn resolve(&self, name: Name) -> Resolving { Box::pin(async move { let host = name.as_str().to_string(); // Port 0: reqwest overrides it with the URL's port after // resolution (same convention as reqwest's default GaiResolver). let resolved = tokio::net::lookup_host((host.as_str(), 0)) .await .map_err(|e| Box::new(e) as Box)?; let public = retain_public_addrs(&host, resolved) .map_err(|e| Box::new(e) as Box)?; Ok(Box::new(public.into_iter()) as Addrs) }) } } /// Shared [`SafeResolver`] for wiring into `ClientBuilder::dns_resolver`. pub fn safe_dns_resolver() -> Arc { Arc::new(SafeResolver) } #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum UrlSafetyError { #[error("URL is not parseable")] Unparseable, #[error("scheme {0:?} is not http or https")] BadScheme(String), #[error("URL is missing a host")] NoHost, #[error("host points at the loopback interface")] Loopback, #[error("host is a private/internal IP: {0}")] PrivateIp(IpAddr), #[error("host {0:?} is not on the crawler download allowlist")] HostNotAllowed(String), } /// Maximum number of redirects the crawler will follow before giving up. /// Matches reqwest's historical default; every hop is re-validated by /// [`check_redirect_hop`], so the cap is a belt-and-braces stop against a /// redirect loop rather than the primary SSRF defence. pub const MAX_REDIRECTS: usize = 10; /// Why a redirect hop was refused. #[derive(Debug, thiserror::Error)] pub enum RedirectError { #[error("redirect chain exceeded {0} hops")] TooManyHops(usize), #[error("redirect target rejected: {0}")] Unsafe(#[from] UrlSafetyError), } /// Decide whether a single redirect hop is safe to follow. /// /// `is_safe_url` only inspects the *initial* URL a caller hands to reqwest; /// without re-validation an allowlisted CDN that answers `302 -> /// http://169.254.169.254/...` or `-> http://127.0.0.1:5432/` would be /// followed transparently (reqwest's default policy follows up to 10 /// redirects). This re-runs the full allowlist + private-IP + scheme check on /// each hop and enforces [`MAX_REDIRECTS`]. `completed_hops` is the number of /// URLs already visited in the chain (reqwest's `attempt.previous().len()`). pub fn check_redirect_hop( next_url: &str, completed_hops: usize, allow: &DownloadAllowlist, ) -> Result<(), RedirectError> { if completed_hops >= MAX_REDIRECTS { return Err(RedirectError::TooManyHops(completed_hops)); } is_safe_url(next_url, allow)?; Ok(()) } /// Build a reqwest redirect policy that re-validates every hop against the /// download allowlist (see [`check_redirect_hop`]). Use for the crawler image /// clients, which fetch attacker-influenced URLs. pub fn safe_redirect_policy(allow: DownloadAllowlist) -> reqwest::redirect::Policy { reqwest::redirect::Policy::custom(move |attempt| { let hops = attempt.previous().len(); match check_redirect_hop(attempt.url().as_str(), hops, &allow) { Ok(()) => attempt.follow(), Err(e) => attempt.error(e), } }) } /// Build a reqwest redirect policy that re-validates every hop with /// [`ensure_public_target`] (scheme + private-IP, no allowlist). Use for /// single-endpoint clients (the analysis vision endpoint / its probe) where /// there is no per-deployment allowlist but a redirect into the deployment's /// internal network must still be refused. pub fn public_redirect_policy() -> reqwest::redirect::Policy { reqwest::redirect::Policy::custom(move |attempt| { let hops = attempt.previous().len(); if hops >= MAX_REDIRECTS { return attempt.error(RedirectError::TooManyHops(hops)); } match ensure_public_target(attempt.url().as_str()) { Ok(()) => attempt.follow(), Err(e) => attempt.error(RedirectError::Unsafe(e)), } }) } /// Drain a byte stream into a single buffer, bailing out as soon as /// the running total exceeds `max_bytes`. Generic over the stream so /// it's testable without a live HTTP response. pub async fn accumulate_capped(stream: S, max_bytes: usize) -> anyhow::Result where S: futures_core::Stream>, E: std::error::Error + Send + Sync + 'static, { let mut buf = BytesMut::new(); let mut stream = std::pin::pin!(stream); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| anyhow::anyhow!("stream chunk: {e}"))?; if buf.len().saturating_add(chunk.len()) > max_bytes { bail!( "response exceeds {max_bytes}-byte cap (received >{}+{})", buf.len(), chunk.len() ); } buf.extend_from_slice(&chunk); } Ok(buf.freeze()) } /// Send `req` and stream the response into a length-limited buffer. /// Combines [`is_safe_url`] check + [`accumulate_capped`] so each /// call-site is one line. pub async fn fetch_bytes_capped( http: &reqwest::Client, url: &str, referer: Option<&str>, allow: &DownloadAllowlist, max_bytes: usize, ) -> anyhow::Result { is_safe_url(url, allow).with_context(|| format!("reject unsafe URL {url}"))?; let mut req = http.get(url); if let Some(r) = referer { req = req.header(reqwest::header::REFERER, r); } let resp = req .send() .await .with_context(|| format!("GET {url}"))? .error_for_status() .with_context(|| format!("non-2xx for {url}"))?; accumulate_capped(resp.bytes_stream(), max_bytes) .await .with_context(|| format!("download body for {url}")) } /// Send `req` and return the response body as a stream after the /// safety check + 2xx status check. Caller owns chunking, capping, and /// piping to storage. Used by `download_and_store_page` so peak memory /// stays at one chunk per concurrent dispatch instead of one full /// image. pub async fn fetch_stream( http: &reqwest::Client, url: &str, referer: Option<&str>, allow: &DownloadAllowlist, ) -> anyhow::Result { is_safe_url(url, allow).with_context(|| format!("reject unsafe URL {url}"))?; let mut req = http.get(url); if let Some(r) = referer { req = req.header(reqwest::header::REFERER, r); } let resp = req .send() .await .with_context(|| format!("GET {url}"))? .error_for_status() .with_context(|| format!("non-2xx for {url}"))?; Ok(resp) } /// True when `bytes` sniffs as one of the *renderable* image formats /// the `/files/*key` endpoint can serve with a correct Content-Type: /// JPEG, PNG, WebP, GIF, AVIF. Matches the upload pipeline's /// whitelist in `upload::parse_image`. /// /// `infer::MatcherType::Image` is intentionally NOT used — it also /// matches BMP, TIFF, HEIF, ICO, PSD, and JP2. Those would sniff as /// "image" here but [`api::files::content_type_for`] would fall back /// to `application/octet-stream`, prompting browsers to download /// instead of render. Keep the two layers aligned. pub fn looks_like_image(bytes: &[u8]) -> bool { matches!( infer::get(bytes).map(|k| k.mime_type()), Some("image/jpeg" | "image/png" | "image/webp" | "image/gif" | "image/avif") ) } #[cfg(test)] mod tests { use super::*; use futures_util::stream; fn allow_just(host: &str) -> DownloadAllowlist { DownloadAllowlist::new().allow(host) } #[test] fn allow_any_admits_arbitrary_public_host() { // Operators who can't pre-enumerate a numbered-CDN fleet // (cdn1, cdn2, …) opt into allow_any. Any public host passes. let allow = DownloadAllowlist::allow_any(); assert!(is_safe_url("https://cdn7.random.tld/x.jpg", &allow).is_ok()); assert!(is_safe_url("https://anything-goes.example/", &allow).is_ok()); } #[test] fn allow_any_still_blocks_private_ips() { // The point of the bypass is the host-allowlist check, not the // SSRF defense. Private/loopback IPs stay refused. let allow = DownloadAllowlist::allow_any(); for url in [ "http://10.0.0.1/", "http://192.168.1.1/", "http://169.254.169.254/", "http://127.0.0.1/", "http://[::1]/", "http://[::ffff:127.0.0.1]/", ] { assert!( matches!( is_safe_url(url, &allow).unwrap_err(), UrlSafetyError::PrivateIp(_) ), "allow_any must still reject {url}" ); } } #[test] fn allow_any_still_blocks_localhost() { let allow = DownloadAllowlist::allow_any(); assert!(matches!( is_safe_url("http://localhost:8080/", &allow).unwrap_err(), UrlSafetyError::Loopback )); } #[test] fn allow_any_still_blocks_non_http_schemes() { let allow = DownloadAllowlist::allow_any(); assert!(matches!( is_safe_url("file:///etc/passwd", &allow).unwrap_err(), UrlSafetyError::BadScheme(_) )); } #[test] fn safe_url_allows_listed_host() { let allow = allow_just("cdn.example.com"); assert!(is_safe_url("https://cdn.example.com/img.jpg", &allow).is_ok()); } #[test] fn safe_url_blocks_unlisted_host() { let allow = allow_just("cdn.example.com"); let err = is_safe_url("https://evil.example.org/img.jpg", &allow).unwrap_err(); assert!(matches!(err, UrlSafetyError::HostNotAllowed(h) if h == "evil.example.org")); } #[test] fn safe_url_blocks_localhost_even_if_allowlisted() { let allow = allow_just("localhost"); assert!(matches!( is_safe_url("http://localhost:8080/", &allow).unwrap_err(), UrlSafetyError::Loopback )); } #[test] fn safe_url_blocks_loopback_ipv4() { let allow = allow_just("127.0.0.1"); assert!(matches!( is_safe_url("http://127.0.0.1/", &allow).unwrap_err(), UrlSafetyError::PrivateIp(_) )); } // --- ensure_public_target (allowlist-free admin URL validation) --- #[test] fn public_target_allows_dns_hostnames() { // The whole point of this helper is to validate admin-supplied // endpoint URLs (analysis vision endpoint, crawler start_url) // WITHOUT consulting an allowlist. Docker-internal DNS names // (which resolve at runtime to private IPs) MUST pass. assert!(ensure_public_target("http://mangalord-vision:8000/v1/chat/completions").is_ok()); assert!(ensure_public_target("https://api.openai.com/v1/chat/completions").is_ok()); assert!(ensure_public_target("https://example.com/").is_ok()); } #[test] fn public_target_blocks_ip_literal_attacks() { // Literal IPs in private/loopback/link-local ranges — the routes // an attacker would actually use (AWS IMDS, postgres on // 127.0.0.1, RFC1918 inside a corp network). for url in [ "http://169.254.169.254/latest/meta-data/", "http://127.0.0.1:5432/", "http://10.0.0.1/", "http://192.168.1.1/", "http://[::1]/", "http://[::ffff:127.0.0.1]/", "http://0.0.0.0/", ] { assert!( matches!( ensure_public_target(url).unwrap_err(), UrlSafetyError::PrivateIp(_) ), "must reject {url}" ); } } #[test] fn public_target_blocks_localhost_hostname() { assert!(matches!( ensure_public_target("http://localhost:5432/").unwrap_err(), UrlSafetyError::Loopback )); } #[test] fn public_target_blocks_non_http_schemes() { for url in ["file:///etc/passwd", "gopher://x.example/", "ftp://x/"] { assert!(matches!( ensure_public_target(url).unwrap_err(), UrlSafetyError::BadScheme(_) )); } } #[test] fn public_target_rejects_unparseable() { assert!(matches!( ensure_public_target("not a url").unwrap_err(), UrlSafetyError::Unparseable )); } // --- back to is_safe_url --- #[test] fn safe_url_blocks_rfc1918() { let allow = allow_just("10.0.0.1"); for url in [ "http://10.0.0.1/", "http://192.168.1.1/", "http://172.16.0.5/", "http://172.31.255.255/", ] { assert!( matches!( is_safe_url(url, &allow).unwrap_err(), UrlSafetyError::PrivateIp(_) ), "should reject {url}" ); } } #[test] fn safe_url_blocks_link_local() { let allow = allow_just("169.254.169.254"); // 169.254.169.254 is the AWS/GCP metadata service — the most // dangerous SSRF target on a default cloud VM. assert!(matches!( is_safe_url("http://169.254.169.254/", &allow).unwrap_err(), UrlSafetyError::PrivateIp(_) )); } #[test] fn safe_url_blocks_ipv6_loopback_and_ula() { // Debug what host_str returns first — reqwest::Url normalises // IPv6 literals as `[::1]` with brackets, which doesn't parse // as `IpAddr` directly. The implementation strips them. let allow = allow_just("[::1]"); let err = is_safe_url("http://[::1]/", &allow).unwrap_err(); assert!( matches!(err, UrlSafetyError::PrivateIp(_)), "expected PrivateIp, got {err:?}" ); let allow = allow_just("[fd00::1]"); let err = is_safe_url("http://[fd00::1]/", &allow).unwrap_err(); assert!( matches!(err, UrlSafetyError::PrivateIp(_)), "expected PrivateIp, got {err:?}" ); } #[test] fn safe_url_blocks_ipv4_mapped_ipv6_loopback() { // `Ipv6Addr::is_loopback()` only matches `::1` exactly, so // `::ffff:127.0.0.1` would slip through without the // to_ipv4_mapped() unwrap in is_private_ip. let allow = allow_just("[::ffff:127.0.0.1]"); let err = is_safe_url("http://[::ffff:127.0.0.1]/", &allow).unwrap_err(); assert!( matches!(err, UrlSafetyError::PrivateIp(_)), "expected PrivateIp, got {err:?}" ); } #[test] fn safe_url_blocks_ipv4_mapped_ipv6_rfc1918() { let allow = allow_just("[::ffff:10.0.0.1]"); let err = is_safe_url("http://[::ffff:10.0.0.1]/", &allow).unwrap_err(); assert!(matches!(err, UrlSafetyError::PrivateIp(_))); } #[test] fn is_private_ip_unwraps_embedded_ipv4_encodings() { // Every IPv6 encoding that can smuggle an internal IPv4 must be // caught. The audit flagged compatible ::/96, NAT64, and 6to4 as // gaps past the original mapped-only handling. for s in [ "::ffff:127.0.0.1", // IPv4-mapped loopback "::127.0.0.1", // IPv4-compatible loopback (was a gap) "::ffff:10.1.2.3", // mapped RFC1918 "::10.1.2.3", // compatible RFC1918 (was a gap) "64:ff9b::7f00:1", // NAT64 of 127.0.0.1 (was a gap) "64:ff9b::a01:203", // NAT64 of 10.1.2.3 "2002:7f00:1::", // 6to4 of 127.0.0.1 (was a gap) "2002:a01:203::", // 6to4 of 10.1.2.3 "2002:a9fe:a9fe::", // 6to4 of 169.254.169.254 (metadata) ] { let ip: IpAddr = s.parse().unwrap(); assert!(is_private_ip(&ip), "{s} must be flagged private"); } } #[test] fn is_private_ip_allows_public_embedded_and_native_ipv6() { // A public IPv4 embedded in IPv6, and a native public IPv6, must // NOT be flagged — the unwrap only blocks when the embedded v4 is // itself private. for s in [ "::ffff:8.8.8.8", // mapped public "2002:808:808::", // 6to4 of 8.8.8.8 (public) "2606:4700:4700::1111", // native public (Cloudflare) ] { let ip: IpAddr = s.parse().unwrap(); assert!(!is_private_ip(&ip), "{s} must be allowed"); } } #[test] fn retain_public_addrs_drops_private_and_errors_when_all_private() { use std::net::{Ipv4Addr, SocketAddr}; let pub_addr = SocketAddr::from((Ipv4Addr::new(93, 184, 216, 34), 0)); let loopback = SocketAddr::from((Ipv4Addr::new(127, 0, 0, 1), 0)); let metadata = SocketAddr::from((Ipv4Addr::new(169, 254, 169, 254), 0)); // Mixed result keeps only the public address. let kept = retain_public_addrs("mixed.example", [pub_addr, loopback, metadata].into_iter()) .expect("public address survives"); assert_eq!(kept, vec![pub_addr]); // All-private (DNS rebinding to internal) is rejected outright. let err = retain_public_addrs("rebind.attacker", [loopback, metadata].into_iter()).unwrap_err(); assert!(err.to_string().contains("rebind.attacker")); } #[test] fn safe_url_blocks_non_http_schemes() { let allow = allow_just("anywhere"); assert!(matches!( is_safe_url("file:///etc/passwd", &allow).unwrap_err(), UrlSafetyError::BadScheme(_) )); assert!(matches!( is_safe_url("gopher://anywhere:70/", &allow).unwrap_err(), UrlSafetyError::BadScheme(_) )); } #[test] fn safe_url_rejects_unparseable() { let allow = allow_just("anywhere"); assert!(matches!( is_safe_url("not a url", &allow).unwrap_err(), UrlSafetyError::Unparseable )); } #[test] fn safe_url_empty_allowlist_rejects_everything() { let allow = DownloadAllowlist::new(); let err = is_safe_url("https://cdn.example.com/img.jpg", &allow).unwrap_err(); assert!(matches!(err, UrlSafetyError::HostNotAllowed(_))); } #[test] fn allowlist_matches_case_insensitively() { let allow = DownloadAllowlist::new().allow("CDN.Example.COM"); assert!(is_safe_url("https://cdn.example.com/x.jpg", &allow).is_ok()); assert!(is_safe_url("https://CDN.EXAMPLE.com/x.jpg", &allow).is_ok()); } // --- redirect-hop re-validation (SSRF via 3xx) --- #[test] fn redirect_hop_allows_listed_public_target() { let allow = allow_just("cdn.example.com"); assert!(check_redirect_hop("https://cdn.example.com/next.jpg", 1, &allow).is_ok()); } #[test] fn redirect_hop_blocks_private_ip_target() { // The core SSRF case: an allowlisted CDN 302s to the cloud metadata // service / an intra-compose port. Must be refused mid-chain. let allow = allow_just("cdn.example.com"); for url in ["http://169.254.169.254/", "http://127.0.0.1:5432/", "http://10.0.0.1/"] { let err = check_redirect_hop(url, 1, &allow).unwrap_err(); assert!( matches!(err, RedirectError::Unsafe(UrlSafetyError::PrivateIp(_))), "expected PrivateIp for {url}, got {err:?}" ); } } #[test] fn redirect_hop_blocks_off_allowlist_public_host() { // Per the strict policy: a redirect to an unlisted *public* host is // also refused (allow_any covers the numbered-CDN case instead). let allow = allow_just("cdn.example.com"); let err = check_redirect_hop("https://evil.example.org/x", 1, &allow).unwrap_err(); assert!(matches!(err, RedirectError::Unsafe(UrlSafetyError::HostNotAllowed(_)))); } #[test] fn redirect_hop_blocks_bad_scheme_target() { let allow = DownloadAllowlist::allow_any(); let err = check_redirect_hop("file:///etc/passwd", 1, &allow).unwrap_err(); assert!(matches!(err, RedirectError::Unsafe(UrlSafetyError::BadScheme(_)))); } #[test] fn redirect_hop_caps_chain_length() { let allow = allow_just("cdn.example.com"); // A safe target is still refused once the hop cap is reached, so a // redirect loop can't spin forever. let err = check_redirect_hop("https://cdn.example.com/x", MAX_REDIRECTS, &allow) .unwrap_err(); assert!(matches!(err, RedirectError::TooManyHops(n) if n == MAX_REDIRECTS)); } #[tokio::test] async fn accumulate_capped_returns_full_body_under_cap() { let chunks: Vec> = vec![ Ok(bytes::Bytes::from_static(b"hello ")), Ok(bytes::Bytes::from_static(b"world")), ]; let s = stream::iter(chunks); let out = accumulate_capped(s, 100).await.unwrap(); assert_eq!(out.as_ref(), b"hello world"); } #[tokio::test] async fn accumulate_capped_bails_past_cap() { let chunks: Vec> = vec![ Ok(bytes::Bytes::from(vec![0u8; 50])), Ok(bytes::Bytes::from(vec![0u8; 60])), ]; let s = stream::iter(chunks); let err = accumulate_capped(s, 100).await.unwrap_err(); assert!(err.to_string().contains("100-byte cap")); } #[tokio::test] async fn accumulate_capped_surfaces_stream_errors() { let chunks: Vec> = vec![ Ok(bytes::Bytes::from_static(b"ok")), Err(std::io::Error::other("network blip")), ]; let s = stream::iter(chunks); let err = accumulate_capped(s, 100).await.unwrap_err(); assert!(err.to_string().contains("network blip")); } #[test] fn looks_like_image_accepts_jpeg() { // JPEG SOI + APP0 segment. let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 0x10, b'J', b'F', b'I', b'F']; assert!(looks_like_image(&jpeg)); } #[test] fn looks_like_image_accepts_png() { let png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]; assert!(looks_like_image(&png)); } #[test] fn looks_like_image_rejects_html_disguised_as_image() { let html = b"not an image"; assert!(!looks_like_image(html)); } #[test] fn looks_like_image_rejects_empty() { assert!(!looks_like_image(&[])); } #[test] fn looks_like_image_rejects_renderable_but_unsupported_formats() { // BMP, TIFF, ICO, PSD are `infer::MatcherType::Image` but the // /files/*key handler doesn't have Content-Type mappings for // them, so they'd be served as application/octet-stream and // download instead of render. Reject at the crawler so we // never land them in storage. // BMP magic: "BM" + 4-byte size. let bmp = [b'B', b'M', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; assert!(!looks_like_image(&bmp), "BMP must be rejected (not renderable by /files)"); // TIFF little-endian magic: "II" + 42. let tiff = [0x49, 0x49, 0x2a, 0x00, 0, 0, 0, 0]; assert!(!looks_like_image(&tiff), "TIFF must be rejected"); // ICO magic: 0x00,0x00,0x01,0x00. let ico = [0x00, 0x00, 0x01, 0x00, 1, 0, 16, 16, 0, 0, 1, 0, 0x18, 0, 0x40, 0, 0, 0, 0x16, 0, 0, 0]; assert!(!looks_like_image(&ico), "ICO must be rejected"); } #[test] fn looks_like_image_accepts_webp_gif_avif() { // Cover the three remaining whitelisted formats so a future // tightening that drops one would fail noisily. let webp = [ b'R', b'I', b'F', b'F', 0, 0, 0, 0, b'W', b'E', b'B', b'P', b'V', b'P', b'8', b' ', ]; assert!(looks_like_image(&webp)); let gif = [b'G', b'I', b'F', b'8', b'7', b'a', 0, 0, 0, 0]; assert!(looks_like_image(&gif)); let avif = [ 0x00, 0x00, 0x00, 0x18, b'f', b't', b'y', b'p', b'a', b'v', b'i', b'f', 0x00, 0x00, 0x00, 0x00, b'm', b'i', b'f', b'1', b'a', b'v', b'i', b'f', ]; assert!(looks_like_image(&avif)); } }