kanidmd_lib/valueset/image/
mod.rs

1use crate::be::dbvalue::DbValueImage;
2use crate::prelude::*;
3use crate::schema::SchemaAttribute;
4use crate::valueset::ScimResolveStatus;
5use crate::valueset::{DbValueSetV2, ValueSet};
6use crypto_glue::{s256::Sha256, traits::Digest};
7use hashbrown::HashSet;
8use image::codecs::gif::GifDecoder;
9use image::codecs::webp::WebPDecoder;
10use image::ImageDecoder;
11use kanidm_proto::internal::{ImageType, ImageValue};
12use std::fmt::Display;
13use std::io::Cursor;
14
15#[derive(Debug, Clone)]
16pub struct ValueSetImage {
17    set: HashSet<ImageValue>,
18}
19
20pub(crate) const MAX_IMAGE_HEIGHT: u32 = 1024;
21pub(crate) const MAX_IMAGE_WIDTH: u32 = 1024;
22pub(crate) const MAX_FILE_SIZE: u32 = 1024 * 256;
23
24const WEBP_MAGIC: &[u8; 4] = b"RIFF";
25
26pub mod jpg;
27pub mod png;
28
29pub trait ImageValueThings {
30    fn validate_image(&self) -> Result<(), ImageValidationError>;
31    fn validate_is_png(&self) -> Result<(), ImageValidationError>;
32    fn validate_is_gif(&self) -> Result<(), ImageValidationError>;
33    fn validate_is_jpg(&self) -> Result<(), ImageValidationError>;
34    fn validate_is_webp(&self) -> Result<(), ImageValidationError>;
35    fn validate_is_svg(&self) -> Result<(), ImageValidationError>;
36
37    /// A sha256 of the filename/type/contents
38    fn hash_imagevalue(&self) -> String;
39
40    fn get_limits(&self) -> image::Limits {
41        let mut limits = image::Limits::default();
42        limits.max_image_height = Some(MAX_IMAGE_HEIGHT);
43        limits.max_image_width = Some(MAX_IMAGE_WIDTH);
44        limits
45    }
46}
47
48#[derive(Debug)]
49pub enum ImageValidationError {
50    Acropalypse(String),
51    ExceedsMaxWidth,
52    ExceedsMaxHeight,
53    ExceedsMaxDimensions,
54    ExceedsMaxFileSize,
55    InvalidImage(String),
56    InvalidPngPrelude,
57}
58
59impl Display for ImageValidationError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            ImageValidationError::ExceedsMaxWidth => {
63                f.write_fmt(format_args!("Exceeds the maximum width: {MAX_IMAGE_WIDTH}"))
64            }
65            ImageValidationError::ExceedsMaxHeight => f.write_fmt(format_args!(
66                "Exceeds the maximum height: {MAX_IMAGE_HEIGHT}"
67            )),
68            ImageValidationError::ExceedsMaxFileSize => {
69                f.write_fmt(format_args!("Exceeds maximum file size of {MAX_FILE_SIZE}"))
70            }
71            ImageValidationError::InvalidImage(message) => {
72                if !message.is_empty() {
73                    f.write_fmt(format_args!("Invalid Image: {message}"))
74                } else {
75                    f.write_str("Invalid Image")
76                }
77            }
78            ImageValidationError::ExceedsMaxDimensions => f.write_fmt(format_args!(
79                "Image exceeds max dimensions of {MAX_IMAGE_WIDTH}x{MAX_IMAGE_HEIGHT}"
80            )),
81            ImageValidationError::Acropalypse(message) => {
82                if !message.is_empty() {
83                    f.write_fmt(format_args!(
84                        "Image has extra data, is vulnerable to Acropalypse: {message}"
85                    ))
86                } else {
87                    f.write_str("Image has extra data, is vulnerable to Acropalypse")
88                }
89            }
90            ImageValidationError::InvalidPngPrelude => {
91                f.write_str("Image has an invalid PNG prelude and is likely corrupt.")
92            }
93        }
94    }
95}
96
97impl ImageValueThings for ImageValue {
98    fn validate_image(&self) -> Result<(), ImageValidationError> {
99        if self.contents.len() > MAX_FILE_SIZE as usize {
100            return Err(ImageValidationError::ExceedsMaxFileSize);
101        }
102
103        match self.filetype {
104            ImageType::Gif => self.validate_is_gif(),
105            ImageType::Png => self.validate_is_png(),
106            ImageType::Svg => self.validate_is_svg(),
107            ImageType::Jpg => self.validate_is_jpg(),
108            ImageType::Webp => self.validate_is_webp(),
109        }
110    }
111
112    /// Validate the PNG file contents, and that it's actually a PNG
113    fn validate_is_png(&self) -> Result<(), ImageValidationError> {
114        // based on code here: https://blog.cloudflare.com/how-cloudflare-images-addressed-the-acropalypse-vulnerability/
115
116        // this takes µs to run, where lodepng takes ms, so it comes first
117        if png::png_has_trailer(&self.contents)? {
118            return Err(ImageValidationError::Acropalypse(
119                "PNG file has a trailer which likely indicates the acropalypse vulnerability!"
120                    .to_string(),
121            ));
122        }
123
124        png::png_lodepng_validate(&self.contents, &self.filename)
125    }
126
127    /// validate the JPG file contents, and that it's actually a JPG
128    fn validate_is_jpg(&self) -> Result<(), ImageValidationError> {
129        // check it starts with a valid header
130        jpg::check_jpg_header(&self.contents)?;
131
132        jpg::validate_decoding(&self.filename, &self.contents, self.get_limits())?;
133
134        if jpg::has_trailer(&self.contents)? {
135            Err(ImageValidationError::Acropalypse(
136                "File has a trailer which likely indicates the acropalypse vulnerability!"
137                    .to_string(),
138            ))
139        } else {
140            Ok(())
141        }
142    }
143
144    /// validate the GIF file contents, and that it's actually a GIF
145    fn validate_is_gif(&self) -> Result<(), ImageValidationError> {
146        let Ok(mut decoder) = GifDecoder::new(Cursor::new(&self.contents[..])) else {
147            return Err(ImageValidationError::InvalidImage(
148                "Failed to parse GIF".to_string(),
149            ));
150        };
151        let limit_result = decoder.set_limits(self.get_limits());
152        if limit_result.is_err() {
153            Err(ImageValidationError::ExceedsMaxDimensions)
154        } else {
155            Ok(())
156        }
157    }
158
159    /// validate the SVG file contents, and that it's actually a SVG (ish)
160    fn validate_is_svg(&self) -> Result<(), ImageValidationError> {
161        // svg is a string so let's do this
162        let svg_string = std::str::from_utf8(&self.contents).map_err(|e| {
163            ImageValidationError::InvalidImage(format!(
164                "Failed to parse SVG {} as a unicode string: {:?}",
165                self.hash_imagevalue(),
166                e
167            ))
168        })?;
169        svg::read(svg_string).map_err(|e| {
170            ImageValidationError::InvalidImage(format!(
171                "Failed to parse {} as SVG: {:?}",
172                self.hash_imagevalue(),
173                e
174            ))
175        })?;
176        Ok(())
177    }
178
179    /// validate the WebP file contents, and that it's actually a WebP file (as far as we can tell)
180    fn validate_is_webp(&self) -> Result<(), ImageValidationError> {
181        if !self.contents.starts_with(WEBP_MAGIC) {
182            return Err(ImageValidationError::InvalidImage(
183                "Failed to parse WebP file, invalid magic bytes".to_string(),
184            ));
185        }
186
187        let Ok(mut decoder) = WebPDecoder::new(Cursor::new(&self.contents[..])) else {
188            return Err(ImageValidationError::InvalidImage(
189                "Failed to parse WebP file".to_string(),
190            ));
191        };
192        match decoder.set_limits(self.get_limits()) {
193            Err(err) => {
194                sketching::admin_warn!(
195                    "Image validation failed while validating {}: {:?}",
196                    self.filename,
197                    err
198                );
199                Err(ImageValidationError::ExceedsMaxDimensions)
200            }
201            Ok(_) => Ok(()),
202        }
203    }
204
205    /// A sha256 of the filename/type/contents, uses openssl so has to live here
206    /// because proto don't need that jazz
207    fn hash_imagevalue(&self) -> String {
208        let filetype_repr = [self.filetype.clone() as u8];
209        let mut hasher = Sha256::new();
210        hasher.update(self.filename.as_bytes());
211        hasher.update(filetype_repr);
212        hasher.update(&self.contents);
213        hex::encode(hasher.finalize())
214    }
215}
216
217impl ValueSetImage {
218    pub fn new(image: ImageValue) -> Box<Self> {
219        let mut set = HashSet::new();
220        match image.validate_image() {
221            Ok(_) => {
222                set.insert(image);
223            }
224            Err(err) => {
225                admin_error!(
226                    "Image {} didn't pass validation, not adding to value! Error: {:?}",
227                    image.filename,
228                    err
229                );
230            }
231        };
232        Box::new(ValueSetImage { set })
233    }
234
235    // add the image, return a bool if there was a change
236    pub fn push(&mut self, image: ImageValue) -> bool {
237        match image.validate_image() {
238            Ok(_) => self.set.insert(image),
239            Err(err) => {
240                admin_error!(
241                    "Image didn't pass validation, not adding to value! Error: {}",
242                    err
243                );
244                false
245            }
246        }
247    }
248
249    pub fn from_dbvs2(data: &[DbValueImage]) -> Result<ValueSet, OperationError> {
250        Ok(Box::new(ValueSetImage {
251            set: data
252                .iter()
253                .cloned()
254                .map(|e| match e {
255                    DbValueImage::V1 {
256                        filename,
257                        filetype,
258                        contents,
259                    } => ImageValue::new(filename, filetype, contents),
260                })
261                .collect(),
262        }))
263    }
264
265    // We need to allow this, because rust doesn't allow us to impl FromIterator on foreign
266    // types, and `ImageValue` is foreign.
267    #[allow(clippy::should_implement_trait)]
268    pub fn from_iter<T>(iter: T) -> Option<Box<ValueSetImage>>
269    where
270        T: IntoIterator<Item = ImageValue>,
271    {
272        let mut set: HashSet<ImageValue> = HashSet::new();
273        for image in iter {
274            match image.validate_image() {
275                Ok(_) => set.insert(image),
276                Err(err) => {
277                    admin_error!(
278                        "Image didn't pass validation, not adding to value! Error: {}",
279                        err
280                    );
281                    return None;
282                }
283            };
284        }
285        Some(Box::new(ValueSetImage { set }))
286    }
287}
288
289impl ValueSetT for ValueSetImage {
290    fn insert_checked(&mut self, value: Value) -> Result<bool, OperationError> {
291        match value {
292            Value::Image(image) => match self.set.contains(&image) {
293                true => Ok(false),             // image exists, no change, return false
294                false => Ok(self.push(image)), // this masks the operationerror
295            },
296            _ => {
297                debug_assert!(false);
298                Err(OperationError::InvalidValueState)
299            }
300        }
301    }
302
303    fn clear(&mut self) {
304        self.set.clear();
305    }
306
307    fn remove(&mut self, pv: &PartialValue, _cid: &Cid) -> bool {
308        match pv {
309            PartialValue::Image(pv) => {
310                let imgset = self.set.clone();
311
312                let res: Vec<bool> = imgset
313                    .iter()
314                    .filter(|image| &image.hash_imagevalue() == pv)
315                    .map(|image| self.set.remove(image))
316                    .collect();
317                res.into_iter().any(|e| e)
318            }
319            _ => {
320                debug_assert!(false);
321                false
322            }
323        }
324    }
325
326    fn contains(&self, pv: &PartialValue) -> bool {
327        match pv {
328            PartialValue::Image(pvhash) => {
329                if let Some(image) = self.set.iter().take(1).next() {
330                    &image.hash_imagevalue() == pvhash
331                } else {
332                    false
333                }
334            }
335            _ => false,
336        }
337    }
338
339    fn substring(&self, _pv: &PartialValue) -> bool {
340        false
341    }
342
343    fn startswith(&self, _pv: &PartialValue) -> bool {
344        false
345    }
346
347    fn endswith(&self, _pv: &PartialValue) -> bool {
348        false
349    }
350
351    fn lessthan(&self, _pv: &PartialValue) -> bool {
352        false
353    }
354
355    fn len(&self) -> usize {
356        self.set.len()
357    }
358
359    fn generate_idx_eq_keys(&self) -> Vec<String> {
360        self.set
361            .iter()
362            .map(|image| image.hash_imagevalue())
363            .collect()
364    }
365
366    fn syntax(&self) -> SyntaxType {
367        SyntaxType::Image
368    }
369
370    fn validate(&self, schema_attr: &SchemaAttribute) -> bool {
371        if !schema_attr.multivalue && self.set.len() > 1 {
372            return false;
373        }
374        self.set.iter().all(|image| {
375            image
376                .validate_image()
377                .map_err(|err| error!("Image {} failed validation: {}", image.filename, err))
378                .is_ok()
379        })
380    }
381
382    fn to_proto_string_clone_iter(&self) -> Box<dyn Iterator<Item = String> + '_> {
383        Box::new(self.set.iter().map(|image| image.hash_imagevalue()))
384    }
385
386    fn to_scim_value(&self) -> Option<ScimResolveStatus> {
387        // TODO: This should be a reference to the image URL, not the image itself!
388        // Does this mean we need to pass in the domain / origin so we can render
389        // these URL's correctly?
390        //
391        // TODO: Currently we don't have a generic way to reference images, we need
392        // to add one.
393        //
394        // TODO: Scim supports a "type" field here, but do we care?
395
396        Some(ScimResolveStatus::Resolved(ScimValueKanidm::from(
397            self.set
398                .iter()
399                .map(|image| image.hash_imagevalue())
400                .collect::<Vec<_>>(),
401        )))
402    }
403
404    fn to_db_valueset_v2(&self) -> DbValueSetV2 {
405        DbValueSetV2::Image(
406            self.set
407                .iter()
408                .cloned()
409                .map(|e| crate::be::dbvalue::DbValueImage::V1 {
410                    filename: e.filename,
411                    filetype: e.filetype,
412                    contents: e.contents,
413                })
414                .collect(),
415        )
416    }
417
418    fn to_partialvalue_iter(&self) -> Box<dyn Iterator<Item = PartialValue> + '_> {
419        Box::new(
420            self.set
421                .iter()
422                .map(|image| PartialValue::Image(image.hash_imagevalue())),
423        )
424    }
425
426    fn to_value_iter(&self) -> Box<dyn Iterator<Item = Value> + '_> {
427        Box::new(self.set.iter().cloned().map(Value::Image))
428    }
429
430    fn equal(&self, other: &ValueSet) -> bool {
431        if let Some(other) = other.as_imageset() {
432            &self.set == other
433        } else {
434            debug_assert!(false);
435            false
436        }
437    }
438
439    fn merge(&mut self, other: &ValueSet) -> Result<(), OperationError> {
440        if let Some(b) = other.as_imageset() {
441            mergesets!(self.set, b)
442        } else {
443            debug_assert!(false);
444            Err(OperationError::InvalidValueState)
445        }
446    }
447
448    fn as_imageset(&self) -> Option<&HashSet<ImageValue>> {
449        Some(&self.set)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    // use super::ValueSetImage;
456    use super::{ImageType, ImageValue, ImageValueThings};
457
458    #[test]
459    /// tests that we can load a bunch of test images and it'll throw errors in a way we expect
460    fn test_imagevalue_loading() {
461        ["gif", "png", "jpg", "webp"]
462            .into_iter()
463            .for_each(|extension| {
464                // test should-be-bad images
465                let filename = format!(
466                    "{}/src/valueset/image/test_images/oversize_dimensions.{extension}",
467                    env!("CARGO_MANIFEST_DIR")
468                );
469                trace!("testing {}", &filename);
470                let image = ImageValue {
471                    filename: format!("oversize_dimensions.{extension}"),
472                    filetype: ImageType::try_from(extension).unwrap(),
473                    contents: std::fs::read(filename).unwrap(),
474                };
475                let res = image.validate_image();
476                trace!("{:?}", &res);
477                assert!(res.is_err());
478
479                let filename = format!(
480                    "{}/src/valueset/image/test_images/ok.svg",
481                    env!("CARGO_MANIFEST_DIR")
482                );
483                let image = ImageValue {
484                    filename: filename.clone(),
485                    filetype: ImageType::Svg,
486                    contents: std::fs::read(&filename).unwrap(),
487                };
488                let res = image.validate_image();
489                trace!("SVG Validation result of {}: {:?}", filename, &res);
490                assert!(res.is_ok());
491                assert!(!image.hash_imagevalue().is_empty());
492            });
493
494        let filename = format!(
495            "{}/src/valueset/image/test_images/ok.svg",
496            env!("CARGO_MANIFEST_DIR")
497        );
498        let image = ImageValue {
499            filename: filename.clone(),
500            filetype: ImageType::Svg,
501            contents: std::fs::read(&filename).unwrap(),
502        };
503        let res = image.validate_image();
504        trace!("SVG Validation result of {}: {:?}", filename, &res);
505        assert!(res.is_ok());
506        assert!(!image.hash_imagevalue().is_empty());
507    }
508
509    // This test is broken on github as it appears to be changing the binary image hash.
510    /*
511    #[test]
512    fn test_scim_imagevalue() {
513        let filename = format!(
514            "{}/src/valueset/image/test_images/ok.jpg",
515            env!("CARGO_MANIFEST_DIR")
516        );
517        let image = ImageValue {
518            filename: filename.clone(),
519            filetype: ImageType::Jpg,
520            contents: std::fs::read(&filename).unwrap(),
521        };
522
523        let vs = ValueSetImage::new(image);
524
525        let data = r#"[
526            "142dc7984dd548dd5dacfe2ad30f8473e3217e39b3b6c8d17a0cf6e4e24b02e0"
527        ]"#;
528
529        crate::valueset::scim_json_reflexive(&vs, data);
530    }
531    */
532}