Skip to main content

imagegen_bridge_artifacts/
remote.rs

1//! SSRF-resistant remote image fetching with DNS pinning and redirect checks.
2
3use 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/// Explicit policy controlling remote image access.
16#[derive(Debug, Clone)]
17pub struct RemoteInputPolicy {
18    /// Whether remote inputs are enabled.
19    pub enabled: bool,
20    /// Exact lower-case hostnames permitted. Empty allows any public hostname.
21    pub allowed_hosts: BTreeSet<String>,
22    /// Allowed destination ports.
23    pub allowed_ports: BTreeSet<u16>,
24    /// Whether private, loopback, link-local, and reserved networks are allowed.
25    pub allow_private_networks: bool,
26    /// Maximum number of checked redirects.
27    pub max_redirects: u8,
28    /// Per-hop request timeout.
29    pub timeout: Duration,
30    /// Maximum URL bytes.
31    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/// Fetches remote images without following unchecked DNS or redirects.
49#[derive(Debug, Clone)]
50pub struct RemoteImageFetcher {
51    policy: RemoteInputPolicy,
52    limits: ImageLimits,
53}
54
55impl RemoteImageFetcher {
56    /// Creates a fetcher. Disabled policy remains a valid fail-closed instance.
57    #[must_use]
58    pub const fn new(policy: RemoteInputPolicy, limits: ImageLimits) -> Self {
59        Self { policy, limits }
60    }
61
62    /// Fetches and verifies one HTTP(S) image.
63    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}