use std::io::Read;
use std::net::IpAddr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemoteImagePolicy {
pub enabled: bool,
pub max_bytes: usize,
pub timeout_secs: u64,
pub allow_private: bool,
}
impl Default for RemoteImagePolicy {
fn default() -> Self {
Self {
enabled: false,
max_bytes: 20 * 1024 * 1024,
timeout_secs: 10,
allow_private: false,
}
}
}
const MAX_REDIRECTS: u32 = 4;
pub fn is_blocked_address(ip: IpAddr) -> bool {
let ip = match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
v4 => v4,
};
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified()
|| (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xffc0) == 0xfe80
}
}
}
fn check_host(host: &str, port: u16, policy: &RemoteImagePolicy) -> Result<(), String> {
if policy.allow_private {
return Ok(());
}
if let Ok(ip) = host.parse::<IpAddr>() {
return if is_blocked_address(ip) {
Err(format!("{ip} is a loopback/private/link-local address"))
} else {
Ok(())
};
}
use std::net::ToSocketAddrs;
let addrs = (host, port)
.to_socket_addrs()
.map_err(|e| format!("could not resolve {host}: {e}"))?;
let mut saw = false;
for a in addrs {
saw = true;
if is_blocked_address(a.ip()) {
return Err(format!(
"{host} resolves to {}, a loopback/private/link-local address",
a.ip()
));
}
}
if saw {
Ok(())
} else {
Err(format!("{host} resolved to no addresses"))
}
}
fn split_url(url: &str) -> Result<(&str, &str, u16), String> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| "not an absolute http(s) URL".to_string())?;
if scheme != "http" && scheme != "https" {
return Err(format!("scheme {scheme:?} is not http or https"));
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let default_port = if scheme == "https" { 443 } else { 80 };
let (host, port) = match authority.rfind(':') {
Some(i) if !authority[i..].contains(']') => (
&authority[..i],
authority[i + 1..].parse().unwrap_or(default_port),
),
_ => (authority, default_port),
};
let host = host.trim_start_matches('[').trim_end_matches(']');
if host.is_empty() {
return Err("URL has no host".to_string());
}
Ok((scheme, host, port))
}
pub fn fetch_as_data_uri(url: &str, policy: &RemoteImagePolicy) -> Result<String, String> {
if !policy.enabled {
return Err("remote image fetching is disabled".to_string());
}
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(policy.timeout_secs)))
.max_redirects(0)
.build()
.into();
let mut current = url.to_string();
for _ in 0..=MAX_REDIRECTS {
let (_scheme, host, port) = split_url(¤t)?;
check_host(host, port, policy)?;
let resp = agent
.get(¤t)
.call()
.map_err(|e| format!("fetch failed: {e}"))?;
let status = resp.status().as_u16();
if (300..400).contains(&status) {
let loc = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| format!("HTTP {status} with no Location header"))?;
if !loc.starts_with("http://") && !loc.starts_with("https://") {
return Err(format!("relative redirect to {loc:?} is not followed"));
}
current = loc.to_string();
continue;
}
if status != 200 {
return Err(format!("HTTP {status}"));
}
let ctype = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let mime = ctype.split(';').next().unwrap_or("").trim().to_lowercase();
if !mime.starts_with("image/") {
return Err(format!("content-type {mime:?} is not an image"));
}
let mut buf = Vec::new();
let cap = policy.max_bytes;
resp.into_body()
.into_reader()
.take(cap as u64 + 1)
.read_to_end(&mut buf)
.map_err(|e| format!("read failed: {e}"))?;
if buf.len() > cap {
return Err(format!(
"image exceeds the {cap}-byte cap (--vision-remote-image-max-mb)"
));
}
if buf.is_empty() {
return Err("fetched an empty body".to_string());
}
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(&buf);
return Ok(format!("data:{mime};base64,{b64}"));
}
Err(format!("more than {MAX_REDIRECTS} redirects"))
}
#[cfg(test)]
#[path = "remote_image_tests.rs"]
mod tests;