Skip to main content

max / makenotwork

19.4 KB · 552 lines History Blame Raw
1 //! S3 storage client for image uploads.
2 //! Delegates S3 operations to the shared `s3_storage` crate.
3
4 use uuid::Uuid;
5
6 use crate::config::S3Config;
7
8 /// S3 client wrapper for image storage.
9 #[derive(Clone)]
10 pub struct S3Storage {
11 inner: s3_storage::S3Client,
12 }
13
14 /// Maximum image size: 5 MB.
15 pub const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024;
16
17 /// Maximum decoded image dimensions (pixels). The 5 MB byte cap does NOT bound
18 /// the decoded pixel count, a lossless PNG/WebP of a solid color compresses a
19 /// gigapixel canvas into a few KB (a decompression / "pixel bomb"). We never
20 /// decode server-side, but a viewer's browser would, so we reject absurd
21 /// declared dimensions up front. 50 MP comfortably clears any real photo
22 /// (an 8000x6000 shot is 48 MP).
23 pub const MAX_IMAGE_PIXELS: u64 = 50_000_000;
24
25 /// Allowed image content types.
26 const ALLOWED_CONTENT_TYPES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
27
28 /// Allowed file extensions (lowercase).
29 #[cfg(test)]
30 const ALLOWED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
31
32 impl S3Storage {
33 /// Create a new S3 client from configuration.
34 pub async fn new(config: &S3Config) -> Result<Self, String> {
35 let s3_config = s3_storage::S3Config {
36 endpoint: config.endpoint.clone(),
37 bucket: config.bucket.clone(),
38 access_key: config.access_key.clone(),
39 secret_key: config.secret_key.clone(),
40 region: config.region.clone(),
41 };
42
43 let inner = s3_storage::S3Client::new(&s3_config).await?;
44 Ok(Self { inner })
45 }
46
47 /// Upload bytes to S3.
48 #[tracing::instrument(skip_all)]
49 pub async fn upload(
50 &self,
51 s3_key: &str,
52 content_type: &str,
53 data: Vec<u8>,
54 ) -> Result<(), String> {
55 self.inner.upload(s3_key, content_type, data, None).await
56 }
57
58 /// Download bytes from S3.
59 #[tracing::instrument(skip_all)]
60 pub async fn download(&self, s3_key: &str) -> Result<(Vec<u8>, String), String> {
61 self.inner.download(s3_key).await
62 }
63
64 /// Open a streaming download from S3 as an HTTP response body, without
65 /// buffering the object in memory.
66 ///
67 /// The image-serve handler streams the result to the client chunk-by-chunk
68 /// instead of materialising a full `Vec<u8>` per request (ultra-fuzz S1). The
69 /// aws `ByteStream` is adapted to a body via its tokio `AsyncBufRead` reader,
70 /// keeping the aws/tokio-util plumbing contained here.
71 #[tracing::instrument(skip_all)]
72 pub async fn download_stream(&self, s3_key: &str) -> Result<axum::body::Body, String> {
73 let stream = self.inner.download_stream(s3_key).await?;
74 let reader = stream.into_async_read();
75 Ok(axum::body::Body::from_stream(
76 tokio_util::io::ReaderStream::new(reader),
77 ))
78 }
79
80 /// Delete an object from S3.
81 #[tracing::instrument(skip_all)]
82 pub async fn delete(&self, s3_key: &str) -> Result<(), String> {
83 self.inner.delete(s3_key).await
84 }
85
86 /// Batch-delete objects in a single request (S3 allows up to 1000 keys).
87 /// Returns the keys that failed to delete, paired with the error message;
88 /// an empty vec means every key was deleted (or was already absent).
89 #[tracing::instrument(skip_all)]
90 pub async fn delete_objects(
91 &self,
92 s3_keys: &[String],
93 ) -> Result<Vec<(String, String)>, String> {
94 self.inner.delete_objects(s3_keys).await
95 }
96 }
97
98 /// Generate an S3 key for a forum image.
99 /// Format: `mt/{community_slug}/{uuid}.{ext}`
100 pub fn generate_image_key(community_slug: &str, ext: &str) -> String {
101 let id = Uuid::new_v4();
102 format!("mt/{community_slug}/{id}.{ext}")
103 }
104
105 /// Sniff the real image format from leading magic bytes, independent of the
106 /// client-declared filename or Content-Type. Returns the canonical extension
107 /// family (`png`/`jpg`/`gif`/`webp`) or `None` if the bytes are not one of the
108 /// four allowed image formats. This is the only attacker-uncontrollable signal
109 /// in the upload, the filename extension and the multipart Content-Type are
110 /// both client-supplied, so a content check that only cross-references those
111 /// two (as the old validator did) is trivially satisfied by a spoofed file.
112 pub fn sniff_image_format(data: &[u8]) -> Option<&'static str> {
113 // PNG: 89 50 4E 47 0D 0A 1A 0A
114 if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
115 return Some("png");
116 }
117 // JPEG: starts with FF D8 FF
118 if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
119 return Some("jpg");
120 }
121 // GIF: "GIF87a" or "GIF89a"
122 if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
123 return Some("gif");
124 }
125 // WebP: "RIFF" .... "WEBP" (bytes 0-3 = RIFF, 8-11 = WEBP)
126 if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
127 return Some("webp");
128 }
129 None
130 }
131
132 /// Read the declared pixel dimensions `(width, height)` straight from the format
133 /// header, without decoding the image. Returns `None` if the header is too short
134 /// or the dimensions can't be located, callers treat that as "can't tell",
135 /// which is safe because the format was already authenticated by
136 /// [`sniff_image_format`] and a real pixel bomb carries parseable dimensions.
137 fn image_dimensions(format: &str, data: &[u8]) -> Option<(u32, u32)> {
138 let be16 = |i: usize| -> Option<u32> {
139 Some(u32::from(u16::from_be_bytes([
140 *data.get(i)?,
141 *data.get(i + 1)?,
142 ])))
143 };
144 let le16 = |i: usize| -> Option<u32> {
145 Some(u32::from(u16::from_le_bytes([
146 *data.get(i)?,
147 *data.get(i + 1)?,
148 ])))
149 };
150 match format {
151 // PNG: IHDR is the first chunk; width/height are big-endian u32 at 16/20.
152 "png" => {
153 let w = u32::from_be_bytes([
154 *data.get(16)?,
155 *data.get(17)?,
156 *data.get(18)?,
157 *data.get(19)?,
158 ]);
159 let h = u32::from_be_bytes([
160 *data.get(20)?,
161 *data.get(21)?,
162 *data.get(22)?,
163 *data.get(23)?,
164 ]);
165 Some((w, h))
166 }
167 // GIF: logical-screen width/height are little-endian u16 at 6/8.
168 "gif" => Some((le16(6)?, le16(8)?)),
169 // JPEG: scan segments for a Start-Of-Frame marker (C0-CF except the
170 // non-SOF C4/C8/CC); height/width are big-endian u16 right after the
171 // 3-byte (length + precision) preamble.
172 "jpg" => {
173 let mut i = 2; // skip SOI (FF D8)
174 while i + 9 < data.len() {
175 if data[i] != 0xFF {
176 i += 1;
177 continue;
178 }
179 let marker = data[i + 1];
180 if (0xC0..=0xCF).contains(&marker)
181 && marker != 0xC4
182 && marker != 0xC8
183 && marker != 0xCC
184 {
185 return Some((be16(i + 7)?, be16(i + 5)?));
186 }
187 // Standalone markers (RSTn, SOI, EOI, TEM) carry no length.
188 if marker == 0xD8
189 || marker == 0xD9
190 || (0xD0..=0xD7).contains(&marker)
191 || marker == 0x01
192 {
193 i += 2;
194 continue;
195 }
196 let seg_len = be16(i + 2)? as usize;
197 if seg_len < 2 {
198 return None;
199 }
200 i += 2 + seg_len;
201 }
202 None
203 }
204 // WebP: three sub-chunk layouts after the "WEBP" tag at byte 12.
205 "webp" => match data.get(12..16)? {
206 b"VP8X" => {
207 // Canvas width/height minus one, 24-bit little-endian at 24/27.
208 let w = 1
209 + (u32::from(*data.get(24)?)
210 | u32::from(*data.get(25)?) << 8
211 | u32::from(*data.get(26)?) << 16);
212 let h = 1
213 + (u32::from(*data.get(27)?)
214 | u32::from(*data.get(28)?) << 8
215 | u32::from(*data.get(29)?) << 16);
216 Some((w, h))
217 }
218 b"VP8L" => {
219 // 14-bit width/height minus one, packed after the 0x2F signature.
220 let b = data.get(21..25)?;
221 let bits = u32::from(b[0])
222 | u32::from(b[1]) << 8
223 | u32::from(b[2]) << 16
224 | u32::from(b[3]) << 24;
225 Some((1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF)))
226 }
227 b"VP8 " => {
228 // Lossy: 14-bit width/height little-endian at 26/28 (after the
229 // 3-byte start code 9D 01 2A).
230 Some((le16(26)? & 0x3FFF, le16(28)? & 0x3FFF))
231 }
232 _ => None,
233 },
234 _ => None,
235 }
236 }
237
238 /// Validate an uploaded file. Returns the sanitized extension and content type.
239 ///
240 /// Defence in depth: the filename extension and the multipart Content-Type must
241 /// agree AND the actual file bytes must sniff to the same image format. A
242 /// polyglot or content-spoofed file (HTML/SVG bytes named `evil.png` with a
243 /// faked `image/png` Content-Type) fails the byte sniff even though it satisfies
244 /// the metadata cross-check.
245 pub fn validate_image(
246 filename: &str,
247 content_type: &str,
248 data: &[u8],
249 ) -> Result<(&'static str, &'static str), &'static str> {
250 let size = data.len();
251 if size > MAX_IMAGE_SIZE {
252 return Err("Image exceeds 5 MB limit.");
253 }
254 if size == 0 {
255 return Err("Empty file.");
256 }
257
258 // Check extension
259 let ext = filename
260 .rsplit('.')
261 .next()
262 .map(str::to_lowercase)
263 .unwrap_or_default();
264
265 let ext_str: &'static str = match ext.as_str() {
266 "png" => "png",
267 "jpg" | "jpeg" => "jpg",
268 "gif" => "gif",
269 "webp" => "webp",
270 _ => return Err("Allowed types: png, jpg, gif, webp."),
271 };
272
273 // Check content type
274 let ct: &'static str = ALLOWED_CONTENT_TYPES
275 .iter()
276 .find(|&&ct| ct == content_type)
277 .copied()
278 .ok_or("Invalid image content type.")?;
279
280 // Cross-validate: extension should match content type
281 let ext_matches = match ext_str {
282 "png" => ct == "image/png",
283 "jpg" => ct == "image/jpeg",
284 "gif" => ct == "image/gif",
285 "webp" => ct == "image/webp",
286 _ => false,
287 };
288 if !ext_matches {
289 return Err("File extension does not match content type.");
290 }
291
292 // Authoritative check: the bytes themselves must be the declared format.
293 match sniff_image_format(data) {
294 Some(sniffed) if sniffed == ext_str => {
295 // Reject decompression / pixel bombs: a tiny file can still declare a
296 // gigapixel canvas that detonates in a viewer's browser. If we can
297 // read the dimensions and they exceed the cap, refuse.
298 if let Some((w, h)) = image_dimensions(ext_str, data)
299 && u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS
300 {
301 return Err("Image dimensions are too large.");
302 }
303 Ok((ext_str, ct))
304 }
305 Some(_) => Err("File contents do not match the declared image type."),
306 None => Err("File is not a valid PNG, JPEG, GIF, or WebP image."),
307 }
308 }
309
310 /// Strip EXIF metadata from JPEG data.
311 /// Works by copying all JPEG segments except APP1 (EXIF) and APP13 (IPTC).
312 /// Returns the cleaned data, or the original data if parsing fails.
313 pub fn strip_exif_jpeg(data: &[u8]) -> Vec<u8> {
314 if data.len() < 4 || data[0] != 0xFF || data[1] != 0xD8 {
315 return data.to_vec(); // Not a valid JPEG
316 }
317
318 let mut out = Vec::with_capacity(data.len());
319 out.extend_from_slice(&[0xFF, 0xD8]); // SOI marker
320
321 let mut i = 2;
322 while i + 1 < data.len() {
323 if data[i] != 0xFF {
324 // Not a marker, copy rest as-is (image data)
325 out.extend_from_slice(&data[i..]);
326 break;
327 }
328
329 let marker = data[i + 1];
330
331 // SOS (Start of Scan), copy the rest verbatim (compressed data follows)
332 if marker == 0xDA {
333 out.extend_from_slice(&data[i..]);
334 break;
335 }
336
337 // Markers without length (RST0-RST7, SOI, EOI, TEM)
338 if marker == 0x00 || marker == 0x01 || (0xD0..=0xD9).contains(&marker) {
339 out.extend_from_slice(&data[i..i + 2]);
340 i += 2;
341 continue;
342 }
343
344 // Read segment length
345 if i + 3 >= data.len() {
346 out.extend_from_slice(&data[i..]);
347 break;
348 }
349 let seg_len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
350 let total = 2 + seg_len; // marker (2) + length includes itself
351
352 if i + total > data.len() {
353 out.extend_from_slice(&data[i..]);
354 break;
355 }
356
357 // Skip APP1 (0xE1 = EXIF) and APP13 (0xED = IPTC/Photoshop)
358 if marker == 0xE1 || marker == 0xED {
359 i += total;
360 continue;
361 }
362
363 out.extend_from_slice(&data[i..i + total]);
364 i += total;
365 }
366
367 out
368 }
369
370 #[cfg(test)]
371 mod tests {
372 use super::*;
373
374 // Minimal byte fixtures carrying each format's real magic bytes.
375 fn png_bytes() -> Vec<u8> {
376 let mut v = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
377 v.extend_from_slice(&[0u8; 64]);
378 v
379 }
380 fn jpeg_bytes() -> Vec<u8> {
381 let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0];
382 v.extend_from_slice(&[0u8; 64]);
383 v
384 }
385 fn gif_bytes() -> Vec<u8> {
386 let mut v = b"GIF89a".to_vec();
387 v.extend_from_slice(&[0u8; 64]);
388 v
389 }
390 fn webp_bytes() -> Vec<u8> {
391 let mut v = b"RIFF\x00\x00\x00\x00WEBP".to_vec();
392 v.extend_from_slice(&[0u8; 64]);
393 v
394 }
395
396 #[test]
397 fn validate_valid_png() {
398 let (ext, ct) = validate_image("photo.png", "image/png", &png_bytes()).unwrap();
399 assert_eq!(ext, "png");
400 assert_eq!(ct, "image/png");
401 }
402
403 #[test]
404 fn validate_valid_jpeg() {
405 let (ext, _) = validate_image("photo.jpg", "image/jpeg", &jpeg_bytes()).unwrap();
406 assert_eq!(ext, "jpg");
407 }
408
409 #[test]
410 fn validate_rejects_oversized() {
411 let mut big = png_bytes();
412 big.resize(6 * 1024 * 1024, 0);
413 let err = validate_image("big.png", "image/png", &big).unwrap_err();
414 assert!(err.contains("5 MB"));
415 }
416
417 #[test]
418 fn validate_rejects_bad_extension() {
419 let err = validate_image("file.exe", "application/octet-stream", &png_bytes()).unwrap_err();
420 assert!(err.contains("Allowed types"));
421 }
422
423 #[test]
424 fn validate_rejects_mismatched_type() {
425 let err = validate_image("photo.png", "image/jpeg", &png_bytes()).unwrap_err();
426 assert!(err.contains("does not match"));
427 }
428
429 #[test]
430 fn validate_rejects_png_pixel_bomb() {
431 // A tiny, valid-looking PNG that declares a 30000x30000 (900 MP) canvas.
432 let mut bomb = png_bytes();
433 let w = 30_000u32.to_be_bytes();
434 let h = 30_000u32.to_be_bytes();
435 bomb[16..20].copy_from_slice(&w);
436 bomb[20..24].copy_from_slice(&h);
437 let err = validate_image("bomb.png", "image/png", &bomb).unwrap_err();
438 assert!(err.contains("dimensions are too large"));
439 }
440
441 #[test]
442 fn validate_accepts_reasonable_png_dimensions() {
443 let mut img = png_bytes();
444 img[16..20].copy_from_slice(&1920u32.to_be_bytes());
445 img[20..24].copy_from_slice(&1080u32.to_be_bytes());
446 assert!(validate_image("ok.png", "image/png", &img).is_ok());
447 }
448
449 #[test]
450 fn validate_rejects_empty() {
451 let err = validate_image("photo.png", "image/png", &[]).unwrap_err();
452 assert!(err.contains("Empty"));
453 }
454
455 #[test]
456 fn validate_rejects_content_spoof() {
457 // Metadata says PNG and they agree, but the bytes are HTML, the classic
458 // polyglot/spoof the old metadata-only check waved through.
459 let html = b"<!DOCTYPE html><script>alert(1)</script>";
460 let err = validate_image("evil.png", "image/png", html).unwrap_err();
461 assert!(err.contains("not a valid"), "got: {err}");
462 }
463
464 #[test]
465 fn validate_rejects_format_mismatch_bytes() {
466 // Declared PNG (ext + content-type agree) but the bytes are a real JPEG.
467 let err = validate_image("photo.png", "image/png", &jpeg_bytes()).unwrap_err();
468 assert!(err.contains("do not match the declared"), "got: {err}");
469 }
470
471 #[test]
472 fn sniff_detects_each_format() {
473 assert_eq!(sniff_image_format(&png_bytes()), Some("png"));
474 assert_eq!(sniff_image_format(&jpeg_bytes()), Some("jpg"));
475 assert_eq!(sniff_image_format(&gif_bytes()), Some("gif"));
476 assert_eq!(sniff_image_format(&webp_bytes()), Some("webp"));
477 }
478
479 #[test]
480 fn sniff_rejects_non_image() {
481 assert_eq!(sniff_image_format(b"<svg xmlns=...>"), None);
482 assert_eq!(sniff_image_format(b""), None);
483 assert_eq!(sniff_image_format(b"RIFF\x00\x00\x00\x00AVI "), None); // RIFF but not WEBP
484 }
485
486 #[test]
487 fn validate_accepts_gif_and_webp() {
488 assert!(validate_image("a.gif", "image/gif", &gif_bytes()).is_ok());
489 assert!(validate_image("a.webp", "image/webp", &webp_bytes()).is_ok());
490 }
491
492 #[test]
493 fn strip_exif_preserves_non_jpeg() {
494 let data = b"not a jpeg";
495 let result = strip_exif_jpeg(data);
496 assert_eq!(result, data);
497 }
498
499 #[test]
500 fn strip_exif_minimal_jpeg() {
501 // Minimal JPEG: SOI + APP0 (JFIF) + SOS + EOI
502 let mut jpeg = vec![0xFF, 0xD8]; // SOI
503 // APP0 segment (marker + length + data)
504 jpeg.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]); // APP0, len=4
505 // APP1 segment (EXIF, should be stripped)
506 jpeg.extend_from_slice(&[0xFF, 0xE1, 0x00, 0x04, 0xAA, 0xBB]); // APP1, len=4
507 // SOS + fake data
508 jpeg.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x02]);
509 jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI
510
511 let result = strip_exif_jpeg(&jpeg);
512 // Should contain SOI, APP0, SOS+data, but NOT APP1
513 assert!(result.len() < jpeg.len(), "EXIF should be stripped");
514 // Check APP1 marker is absent
515 let has_app1 = result.windows(2).any(|w| w == [0xFF, 0xE1]);
516 assert!(!has_app1, "APP1 should be removed");
517 // Check APP0 is preserved
518 let has_app0 = result.windows(2).any(|w| w == [0xFF, 0xE0]);
519 assert!(has_app0, "APP0 should be preserved");
520 }
521
522 #[test]
523 fn generate_key_format() {
524 let key = generate_image_key("test-comm", "png");
525 assert!(key.starts_with("mt/test-comm/"));
526 assert!(
527 std::path::Path::new(&key)
528 .extension()
529 .is_some_and(|ext| ext.eq_ignore_ascii_case("png"))
530 );
531 }
532
533 #[test]
534 fn allowed_extensions_match() {
535 // Verify our ALLOWED_EXTENSIONS list matches validate_image behavior
536 for ext in ALLOWED_EXTENSIONS {
537 let filename = format!("test.{ext}");
538 let (ct, bytes) = match *ext {
539 "png" => ("image/png", png_bytes()),
540 "jpg" | "jpeg" => ("image/jpeg", jpeg_bytes()),
541 "gif" => ("image/gif", gif_bytes()),
542 "webp" => ("image/webp", webp_bytes()),
543 _ => continue,
544 };
545 assert!(
546 validate_image(&filename, ct, &bytes).is_ok(),
547 "Extension {ext} should be valid"
548 );
549 }
550 }
551 }
552