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