kanidmd_lib/valueset/image/
mod.rs

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