Skip to main content

iris_chat_core/
image_proxy.rs

1use crate::state::PreferencesSnapshot;
2use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
3use hmac::{Hmac, Mac};
4use sha2::Sha256;
5use url::Url;
6
7pub const DEFAULT_IMAGE_PROXY_URL: &str = "https://imgproxy.iris.to";
8pub const DEFAULT_IMAGE_PROXY_KEY_HEX: &str =
9    "f66233cb160ea07078ff28099bfa3e3e654bc10aa4a745e12176c433d79b8996";
10pub const DEFAULT_IMAGE_PROXY_SALT_HEX: &str =
11    "5e608e60945dcd2a787e8465d76ba34149894765061d39287609fb9d776caa0c";
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// Ordered image sources. Loaders may try the next source only after the
16/// previous request or image decoding fails. An empty list means do not load.
17pub fn image_load_urls(
18    original_src: &str,
19    preferences: &PreferencesSnapshot,
20    width: Option<u32>,
21    height: Option<u32>,
22    square: bool,
23) -> Vec<String> {
24    let original = original_src.trim();
25    let primary = proxied_image_url(original, preferences, width, height, square);
26    if primary.is_empty() {
27        return Vec::new();
28    }
29    let allow_fallback = preferences.image_proxy_enabled
30        && preferences.image_proxy_fallback_enabled
31        && primary != original
32        && Url::parse(original).is_ok_and(|url| is_http_url(&url));
33    let mut urls = vec![primary];
34    if allow_fallback {
35        urls.push(original.to_string());
36    }
37    urls
38}
39
40pub fn proxied_image_url(
41    original_src: &str,
42    preferences: &PreferencesSnapshot,
43    width: Option<u32>,
44    height: Option<u32>,
45    square: bool,
46) -> String {
47    let input = original_src.trim();
48    if input.is_empty() || !preferences.image_proxy_enabled {
49        return original_src.to_string();
50    }
51    if input.starts_with("data:") || input.starts_with("blob:") {
52        return original_src.to_string();
53    }
54
55    let Ok(source_url) = Url::parse(input) else {
56        return String::new();
57    };
58    if !is_http_url(&source_url) {
59        return original_src.to_string();
60    }
61
62    let proxy_base = resolved_proxy_url(preferences);
63    let Ok(proxy_url) = Url::parse(&proxy_base) else {
64        return proxy_failure_fallback(original_src, preferences);
65    };
66    if !is_http_url(&proxy_url) {
67        return proxy_failure_fallback(original_src, preferences);
68    }
69    // A string prefix also matches attacker-controlled hosts and userinfo.
70    // Only URLs within the configured proxy's origin and path are already proxied.
71    let proxy_path = proxy_url.path().trim_end_matches('/');
72    if source_url.origin() == proxy_url.origin()
73        && (source_url.path() == proxy_path
74            || source_url
75                .path()
76                .strip_prefix(proxy_path)
77                .is_some_and(|suffix| suffix.starts_with('/')))
78    {
79        return original_src.to_string();
80    }
81
82    let mut options = Vec::new();
83    if let (Some(resize_width), Some(resize_height)) = (
84        normalized_dimension(width, height),
85        normalized_dimension(height, width),
86    ) {
87        let mode = if square { "fill" } else { "fit" };
88        options.push(format!("rs:{mode}:{resize_width}:{resize_height}"));
89    }
90    options.push("dpr:2".to_string());
91
92    let encoded_source = URL_SAFE_NO_PAD.encode(input.as_bytes());
93    let path = format!("/{}/{}", options.join("/"), encoded_source);
94    let Some(signature) = sign_path(&path, preferences) else {
95        return proxy_failure_fallback(original_src, preferences);
96    };
97
98    format!("{}/{}{}", proxy_base.trim_end_matches('/'), signature, path)
99}
100
101fn proxy_failure_fallback(original_src: &str, preferences: &PreferencesSnapshot) -> String {
102    if preferences.image_proxy_fallback_enabled {
103        original_src.to_string()
104    } else {
105        String::new()
106    }
107}
108
109fn resolved_proxy_url(preferences: &PreferencesSnapshot) -> String {
110    let trimmed = preferences.image_proxy_url.trim();
111    if trimmed.is_empty() {
112        DEFAULT_IMAGE_PROXY_URL.to_string()
113    } else {
114        trimmed.to_string()
115    }
116}
117
118fn sign_path(path: &str, preferences: &PreferencesSnapshot) -> Option<String> {
119    let key = decode_hex(resolved_hex(
120        &preferences.image_proxy_key_hex,
121        DEFAULT_IMAGE_PROXY_KEY_HEX,
122    ))?;
123    let salt = decode_hex(resolved_hex(
124        &preferences.image_proxy_salt_hex,
125        DEFAULT_IMAGE_PROXY_SALT_HEX,
126    ))?;
127    let mut mac = HmacSha256::new_from_slice(&key).ok()?;
128    mac.update(&salt);
129    mac.update(path.as_bytes());
130    Some(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
131}
132
133fn resolved_hex<'a>(value: &'a str, fallback: &'a str) -> &'a str {
134    let trimmed = value.trim();
135    if trimmed.is_empty() {
136        fallback
137    } else {
138        trimmed
139    }
140}
141
142fn decode_hex(value: &str) -> Option<Vec<u8>> {
143    let normalized = value.trim();
144    if normalized.is_empty() || !normalized.len().is_multiple_of(2) {
145        return None;
146    }
147    let mut bytes = Vec::with_capacity(normalized.len() / 2);
148    for pair in normalized.as_bytes().chunks(2) {
149        let [first, second] = <[u8; 2]>::try_from(pair).ok()?;
150        let high = hex_value(first)?;
151        let low = hex_value(second)?;
152        bytes.push((high << 4) | low);
153    }
154    Some(bytes)
155}
156
157fn hex_value(value: u8) -> Option<u8> {
158    match value {
159        b'0'..=b'9' => Some(value - b'0'),
160        b'a'..=b'f' => Some(value - b'a' + 10),
161        b'A'..=b'F' => Some(value - b'A' + 10),
162        _ => None,
163    }
164}
165
166fn is_http_url(url: &Url) -> bool {
167    matches!(url.scheme(), "http" | "https") && url.host_str().is_some()
168}
169
170fn normalized_dimension(value: Option<u32>, fallback: Option<u32>) -> Option<u32> {
171    match value.or(fallback) {
172        Some(candidate) if candidate > 0 => Some(candidate),
173        _ => None,
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn preferences() -> PreferencesSnapshot {
182        PreferencesSnapshot::default()
183    }
184
185    #[test]
186    fn disabled_proxy_returns_original_url() {
187        let mut preferences = preferences();
188        preferences.image_proxy_enabled = false;
189        let input = "https://example.com/avatar.jpg";
190
191        assert_eq!(
192            proxied_image_url(input, &preferences, Some(64), Some(64), false),
193            input
194        );
195    }
196
197    #[test]
198    fn ignores_non_http_data_blob_and_existing_proxy_urls() {
199        let preferences = preferences();
200
201        assert_eq!(
202            proxied_image_url("data:image/png;base64,abc", &preferences, None, None, false),
203            "data:image/png;base64,abc"
204        );
205        assert_eq!(
206            proxied_image_url(
207                "blob:https://example.com/123",
208                &preferences,
209                None,
210                None,
211                false
212            ),
213            "blob:https://example.com/123"
214        );
215        assert_eq!(
216            proxied_image_url("file:///tmp/avatar.jpg", &preferences, None, None, false),
217            "file:///tmp/avatar.jpg"
218        );
219        assert_eq!(
220            proxied_image_url(
221                "https://imgproxy.iris.to/signature/dpr:2/source",
222                &preferences,
223                None,
224                None,
225                false,
226            ),
227            "https://imgproxy.iris.to/signature/dpr:2/source"
228        );
229    }
230
231    #[test]
232    fn generates_deterministic_signed_proxy_url() {
233        let preferences = preferences();
234        let input = "https://example.com/avatar.jpg";
235
236        let proxied = proxied_image_url(input, &preferences, Some(64), Some(64), true);
237
238        assert!(proxied.starts_with("https://imgproxy.iris.to/"));
239        assert!(proxied.contains("/rs:fill:64:64/"));
240        assert!(proxied.contains("/dpr:2/"));
241        assert!(proxied.contains("aHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIuanBn"));
242        assert_eq!(
243            proxied_image_url(input, &preferences, Some(64), Some(64), true),
244            proxied
245        );
246    }
247
248    #[test]
249    fn lookalike_proxy_urls_are_still_proxied() {
250        let preferences = preferences();
251        for input in [
252            "https://imgproxy.iris.to.attacker.example/avatar.jpg",
253            "https://imgproxy.iris.to@attacker.example/avatar.jpg",
254            "https://imgproxy.iris.to:8443/avatar.jpg",
255        ] {
256            let proxied = proxied_image_url(input, &preferences, None, None, false);
257            assert_ne!(proxied, input);
258            assert!(proxied.starts_with("https://imgproxy.iris.to/"));
259            assert!(proxied.ends_with(&URL_SAFE_NO_PAD.encode(input.as_bytes())));
260        }
261    }
262
263    #[test]
264    fn proxy_path_must_match_at_a_segment_boundary() {
265        let mut preferences = preferences();
266        preferences.image_proxy_url = "https://images.example/proxy".to_string();
267        let existing = "https://images.example/proxy/signature/dpr:2/source";
268        assert_eq!(
269            proxied_image_url(existing, &preferences, None, None, false),
270            existing
271        );
272        let input = "https://images.example/proxy-other/avatar.jpg";
273        assert_ne!(
274            proxied_image_url(input, &preferences, None, None, false),
275            input
276        );
277    }
278
279    #[test]
280    fn invalid_key_or_salt_requires_opt_in_to_load_original_url() {
281        let mut preferences = preferences();
282        preferences.image_proxy_key_hex = "not-hex".to_string();
283        preferences.image_proxy_salt_hex = "also-not-hex".to_string();
284        let input = "https://example.com/avatar.jpg";
285
286        assert_eq!(
287            proxied_image_url(input, &preferences, None, None, false),
288            ""
289        );
290        assert!(image_load_urls(input, &preferences, None, None, false).is_empty());
291        preferences.image_proxy_fallback_enabled = true;
292        assert_eq!(
293            image_load_urls(input, &preferences, None, None, false),
294            vec![input]
295        );
296    }
297
298    #[test]
299    fn image_load_candidates_require_explicit_fallback_opt_in() {
300        let mut preferences = preferences();
301        let input = "https://example.com/avatar.jpg";
302        let proxy = proxied_image_url(input, &preferences, Some(64), Some(64), true);
303        assert!(!preferences.image_proxy_fallback_enabled);
304        assert_ne!(proxy, input);
305        assert_eq!(
306            image_load_urls(input, &preferences, Some(64), Some(64), true),
307            vec![proxy.clone()]
308        );
309        preferences.image_proxy_fallback_enabled = true;
310        assert_eq!(
311            image_load_urls(input, &preferences, Some(64), Some(64), true),
312            vec![proxy, input.to_string()]
313        );
314        preferences.image_proxy_enabled = false;
315        assert_eq!(
316            image_load_urls(input, &preferences, None, None, false),
317            vec![input]
318        );
319    }
320
321    #[test]
322    fn invalid_proxy_configuration_fails_closed_unless_fallback_is_enabled() {
323        for proxy in ["not a URL", "file:///private/proxy", "https://"] {
324            let mut preferences = preferences();
325            preferences.image_proxy_url = proxy.to_string();
326            let input = "https://example.com/avatar.jpg";
327            assert!(image_load_urls(input, &preferences, None, None, false).is_empty());
328            preferences.image_proxy_fallback_enabled = true;
329            assert_eq!(
330                image_load_urls(input, &preferences, None, None, false),
331                vec![input]
332            );
333        }
334    }
335
336    #[test]
337    fn image_load_candidates_do_not_duplicate_sources_or_proxy_local_images() {
338        let mut preferences = preferences();
339        preferences.image_proxy_fallback_enabled = true;
340        for input in [
341            "https://imgproxy.iris.to/signature/dpr:2/source",
342            "data:image/png;base64,abc",
343            "file:///tmp/avatar.jpg",
344            "htree://nhash1abc123/photo.png",
345        ] {
346            assert_eq!(
347                image_load_urls(input, &preferences, None, None, false),
348                vec![input]
349            );
350        }
351        assert!(image_load_urls("  ", &preferences, None, None, false).is_empty());
352    }
353}