imagegen_bridge_artifacts/
remote.rs1use std::{
4 collections::BTreeSet,
5 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
6 time::Duration,
7};
8
9use futures_util::StreamExt as _;
10use imagegen_bridge_core::{BridgeError, ErrorCode};
11use reqwest::{Client, StatusCode, Url, header};
12
13use crate::{ImageLimits, LoadedImage, inspect_image};
14
15#[derive(Debug, Clone)]
17pub struct RemoteInputPolicy {
18 pub enabled: bool,
20 pub allowed_hosts: BTreeSet<String>,
22 pub allowed_ports: BTreeSet<u16>,
24 pub allow_private_networks: bool,
26 pub max_redirects: u8,
28 pub timeout: Duration,
30 pub max_url_bytes: usize,
32}
33
34impl Default for RemoteInputPolicy {
35 fn default() -> Self {
36 Self {
37 enabled: false,
38 allowed_hosts: BTreeSet::new(),
39 allowed_ports: BTreeSet::from([80, 443]),
40 allow_private_networks: false,
41 max_redirects: 3,
42 timeout: Duration::from_secs(20),
43 max_url_bytes: 8 * 1024,
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct RemoteImageFetcher {
51 policy: RemoteInputPolicy,
52 limits: ImageLimits,
53}
54
55impl RemoteImageFetcher {
56 #[must_use]
58 pub const fn new(policy: RemoteInputPolicy, limits: ImageLimits) -> Self {
59 Self { policy, limits }
60 }
61
62 pub async fn fetch(&self, value: &str) -> Result<LoadedImage, BridgeError> {
64 if !self.policy.enabled {
65 return Err(remote_error("remote image inputs are disabled"));
66 }
67 if value.len() > self.policy.max_url_bytes {
68 return Err(remote_error(
69 "remote image URL exceeds the configured limit",
70 ));
71 }
72 let mut url = Url::parse(value).map_err(|_| remote_error("remote image URL is invalid"))?;
73 for redirect_count in 0..=self.policy.max_redirects {
74 self.validate_url(&url)?;
75 let client = self.pinned_client(&url).await?;
76 let response = client
77 .get(url.clone())
78 .header(header::ACCEPT, "image/png,image/jpeg,image/webp;q=0.9")
79 .send()
80 .await
81 .map_err(|_| remote_error("remote image request failed"))?;
82
83 if response.status().is_redirection() {
84 if redirect_count == self.policy.max_redirects {
85 return Err(remote_error("remote image redirect limit exceeded"));
86 }
87 url = checked_redirect(&url, response.status(), response.headers())?;
88 continue;
89 }
90 if !response.status().is_success() {
91 return Err(remote_error(format!(
92 "remote image returned HTTP {}",
93 response.status().as_u16()
94 )));
95 }
96 validate_content_headers(&response, self.limits)?;
97 let mut stream = response.bytes_stream();
98 let mut bytes = Vec::new();
99 while let Some(chunk) = stream.next().await {
100 let chunk = chunk.map_err(|_| remote_error("remote image body failed"))?;
101 let new_len = bytes.len().checked_add(chunk.len()).ok_or_else(|| {
102 remote_error("remote image exceeds the configured byte limit")
103 })?;
104 if u64::try_from(new_len).unwrap_or(u64::MAX) > self.limits.max_encoded_bytes {
105 return Err(remote_error(
106 "remote image exceeds the configured byte limit",
107 ));
108 }
109 bytes.extend_from_slice(&chunk);
110 }
111 let metadata = inspect_image(&bytes, self.limits)?;
112 return Ok(LoadedImage {
113 bytes,
114 metadata,
115 filename: None,
116 });
117 }
118 Err(remote_error("remote image redirect limit exceeded"))
119 }
120
121 fn validate_url(&self, url: &Url) -> Result<(), BridgeError> {
122 if !matches!(url.scheme(), "http" | "https") {
123 return Err(remote_error("remote image URL must use http or https"));
124 }
125 if !url.username().is_empty() || url.password().is_some() {
126 return Err(remote_error(
127 "remote image URL must not contain credentials",
128 ));
129 }
130 let host = url
131 .host_str()
132 .ok_or_else(|| remote_error("remote image URL has no hostname"))?
133 .to_ascii_lowercase();
134 if !self.policy.allowed_hosts.is_empty() && !self.policy.allowed_hosts.contains(&host) {
135 return Err(remote_error("remote image hostname is not allowed"));
136 }
137 let port = url
138 .port_or_known_default()
139 .ok_or_else(|| remote_error("remote image URL has no usable port"))?;
140 if !self.policy.allowed_ports.contains(&port) {
141 return Err(remote_error("remote image port is not allowed"));
142 }
143 Ok(())
144 }
145
146 async fn pinned_client(&self, url: &Url) -> Result<Client, BridgeError> {
147 let host = url
148 .host_str()
149 .ok_or_else(|| remote_error("remote image URL has no hostname"))?;
150 let port = url
151 .port_or_known_default()
152 .ok_or_else(|| remote_error("remote image URL has no usable port"))?;
153 let addresses: Vec<SocketAddr> = tokio::net::lookup_host((host, port))
154 .await
155 .map_err(|_| remote_error("remote image hostname could not be resolved"))?
156 .collect();
157 if addresses.is_empty() {
158 return Err(remote_error(
159 "remote image hostname resolved to no addresses",
160 ));
161 }
162 if !self.policy.allow_private_networks
163 && addresses.iter().any(|address| !is_public_ip(address.ip()))
164 {
165 return Err(remote_error(
166 "remote image hostname resolves to a private or reserved address",
167 ));
168 }
169 Client::builder()
170 .redirect(reqwest::redirect::Policy::none())
171 .no_proxy()
172 .timeout(self.policy.timeout)
173 .user_agent(concat!("imagegen-bridge/", env!("CARGO_PKG_VERSION")))
174 .resolve_to_addrs(host, &addresses)
175 .build()
176 .map_err(|_| remote_error("remote image client could not be initialized"))
177 }
178}
179
180fn checked_redirect(
181 current: &Url,
182 status: StatusCode,
183 headers: &header::HeaderMap,
184) -> Result<Url, BridgeError> {
185 if !matches!(
186 status,
187 StatusCode::MOVED_PERMANENTLY
188 | StatusCode::FOUND
189 | StatusCode::SEE_OTHER
190 | StatusCode::TEMPORARY_REDIRECT
191 | StatusCode::PERMANENT_REDIRECT
192 ) {
193 return Err(remote_error("unsupported redirect response"));
194 }
195 let location = headers
196 .get(header::LOCATION)
197 .ok_or_else(|| remote_error("redirect response has no location"))?
198 .to_str()
199 .map_err(|_| remote_error("redirect location is invalid"))?;
200 current
201 .join(location)
202 .map_err(|_| remote_error("redirect location is invalid"))
203}
204
205fn validate_content_headers(
206 response: &reqwest::Response,
207 limits: ImageLimits,
208) -> Result<(), BridgeError> {
209 if response
210 .content_length()
211 .is_some_and(|length| length > limits.max_encoded_bytes)
212 {
213 return Err(remote_error(
214 "remote image content length exceeds the configured limit",
215 ));
216 }
217 if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
218 let content_type = content_type
219 .to_str()
220 .map_err(|_| remote_error("remote image content type is invalid"))?
221 .split(';')
222 .next()
223 .unwrap_or_default()
224 .trim()
225 .to_ascii_lowercase();
226 if !matches!(
227 content_type.as_str(),
228 "image/png" | "image/jpeg" | "image/webp" | "application/octet-stream"
229 ) {
230 return Err(remote_error(
231 "remote response does not declare a supported image content type",
232 ));
233 }
234 }
235 Ok(())
236}
237
238fn is_public_ip(address: IpAddr) -> bool {
239 match address {
240 IpAddr::V4(address) => is_public_ipv4(address),
241 IpAddr::V6(address) => {
242 if let Some(mapped) = address.to_ipv4_mapped() {
243 return is_public_ipv4(mapped);
244 }
245 !address.is_loopback()
246 && !address.is_unspecified()
247 && !address.is_multicast()
248 && !address.is_unique_local()
249 && !address.is_unicast_link_local()
250 && !is_ipv6_documentation(address)
251 }
252 }
253}
254
255fn is_public_ipv4(address: Ipv4Addr) -> bool {
256 let octets = address.octets();
257 !address.is_private()
258 && !address.is_loopback()
259 && !address.is_link_local()
260 && !address.is_broadcast()
261 && !address.is_documentation()
262 && !address.is_unspecified()
263 && !address.is_multicast()
264 && !(octets[0] == 100 && (64..=127).contains(&octets[1]))
265 && !(octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
266 && !(octets[0] == 198 && matches!(octets[1], 18 | 19))
267 && octets[0] < 240
268}
269
270fn is_ipv6_documentation(address: Ipv6Addr) -> bool {
271 let segments = address.segments();
272 segments[0] == 0x2001 && segments[1] == 0x0db8
273}
274
275fn remote_error(message: impl Into<String>) -> BridgeError {
276 BridgeError::new(ErrorCode::Input, message)
277}
278
279#[cfg(test)]
280mod tests {
281 #![allow(clippy::unwrap_used)]
282
283 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
284
285 use super::*;
286 use crate::inspect::test_png;
287
288 async fn serve_once(body: Vec<u8>, content_type: &str) -> SocketAddr {
289 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
290 let address = listener.local_addr().unwrap();
291 let content_type = content_type.to_owned();
292 tokio::spawn(async move {
293 let (mut stream, _) = listener.accept().await.unwrap();
294 let mut request = [0_u8; 2048];
295 let _ = stream.read(&mut request).await.unwrap();
296 let headers = format!(
297 "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
298 body.len()
299 );
300 stream.write_all(headers.as_bytes()).await.unwrap();
301 stream.write_all(&body).await.unwrap();
302 });
303 address
304 }
305
306 #[tokio::test]
307 async fn blocks_loopback_by_default() {
308 let fetcher = RemoteImageFetcher::new(
309 RemoteInputPolicy {
310 enabled: true,
311 ..RemoteInputPolicy::default()
312 },
313 ImageLimits::default(),
314 );
315 let error = fetcher
316 .fetch("http://127.0.0.1/image.png")
317 .await
318 .unwrap_err();
319 assert!(error.message.contains("private or reserved"));
320 }
321
322 #[tokio::test]
323 async fn fetches_bounded_image_when_private_network_is_explicitly_allowed() {
324 let body = test_png(2, 3);
325 let address = serve_once(body.clone(), "image/png").await;
326 let fetcher = RemoteImageFetcher::new(
327 RemoteInputPolicy {
328 enabled: true,
329 allowed_ports: BTreeSet::from([address.port()]),
330 allow_private_networks: true,
331 ..RemoteInputPolicy::default()
332 },
333 ImageLimits::default(),
334 );
335 let image = fetcher
336 .fetch(&format!("http://{address}/image.png"))
337 .await
338 .unwrap();
339 assert_eq!(image.bytes, body);
340 assert_eq!((image.metadata.width, image.metadata.height), (2, 3));
341 }
342
343 #[test]
344 fn reserved_ranges_are_not_public() {
345 for address in [
346 "10.0.0.1",
347 "127.0.0.1",
348 "169.254.1.1",
349 "100.64.0.1",
350 "192.0.2.1",
351 "198.18.0.1",
352 "224.0.0.1",
353 "240.0.0.1",
354 "::1",
355 "fc00::1",
356 "fe80::1",
357 "2001:db8::1",
358 ] {
359 assert!(
360 !is_public_ip(address.parse().unwrap()),
361 "accepted {address}"
362 );
363 }
364 }
365}