Skip to main content

max / makenotwork

19.2 KB · 547 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 let ext = filename
259 .rsplit('.')
260 .next()
261 .map(str::to_lowercase)
262 .unwrap_or_default();
263
264 let ext_str: &'static str = match ext.as_str() {
265 "png" => "png",
266 "jpg" | "jpeg" => "jpg",
267 "gif" => "gif",
268 "webp" => "webp",
269 _ => return Err("Allowed types: png, jpg, gif, webp."),
270 };
271
272 let ct: &'static str = ALLOWED_CONTENT_TYPES
273 .iter()
274 .find(|&&ct| ct == content_type)
275 .copied()
276 .ok_or("Invalid image content type.")?;
277
278 // Cross-validate: extension should match content type
279 let ext_matches = match ext_str {
280 "png" => ct == "image/png",
281 "jpg" => ct == "image/jpeg",
282 "gif" => ct == "image/gif",
283 "webp" => ct == "image/webp",
284 _ => false,
285 };
286 if !ext_matches {
287 return Err("File extension does not match content type.");
288 }
289
290 // Authoritative check: the bytes themselves must be the declared format.
291 match sniff_image_format(data) {
292 Some(sniffed) if sniffed == ext_str => {
293 // Reject decompression / pixel bombs: a tiny file can still declare a
294 // gigapixel canvas that detonates in a viewer's browser. If we can
295 // read the dimensions and they exceed the cap, refuse.
296 if let Some((w, h)) = image_dimensions(ext_str, data)
297 && u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS
298 {
299 return Err("Image dimensions are too large.");
300 }
301 Ok((ext_str, ct))
302 }
303 Some(_) => Err("File contents do not match the declared image type."),
304 None => Err("File is not a valid PNG, JPEG, GIF, or WebP image."),
305 }
306 }
307
308 /// Strip EXIF metadata from JPEG data.
309 /// Works by copying all JPEG segments except APP1 (EXIF) and APP13 (IPTC).
310 /// Returns the cleaned data, or the original data if parsing fails.
311 pub fn strip_exif_jpeg(data: &[u8]) -> Vec<u8> {
312 if data.len() < 4 || data[0] != 0xFF || data[1] != 0xD8 {
313 return data.to_vec(); // Not a valid JPEG
314 }
315
316 let mut out = Vec::with_capacity(data.len());
317 out.extend_from_slice(&[0xFF, 0xD8]); // SOI marker
318
319 let mut i = 2;
320 while i + 1 < data.len() {
321 if data[i] != 0xFF {
322 // Not a marker, copy rest as-is (image data)
323 out.extend_from_slice(&data[i..]);
324 break;
325 }
326
327 let marker = data[i + 1];
328
329 // SOS (Start of Scan), copy the rest verbatim (compressed data follows)
330 if marker == 0xDA {
331 out.extend_from_slice(&data[i..]);
332 break;
333 }
334
335 // Markers without length (RST0-RST7, SOI, EOI, TEM)
336 if marker == 0x00 || marker == 0x01 || (0xD0..=0xD9).contains(&marker) {
337 out.extend_from_slice(&data[i..i + 2]);
338 i += 2;
339 continue;
340 }
341
342 if i + 3 >= data.len() {
343 out.extend_from_slice(&data[i..]);
344 break;
345 }
346 let seg_len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
347 let total = 2 + seg_len; // marker (2) + length includes itself
348
349 if i + total > data.len() {
350 out.extend_from_slice(&data[i..]);
351 break;
352 }
353
354 // Skip APP1 (0xE1 = EXIF) and APP13 (0xED = IPTC/Photoshop)
355 if marker == 0xE1 || marker == 0xED {
356 i += total;
357 continue;
358 }
359
360 out.extend_from_slice(&data[i..i + total]);
361 i += total;
362 }
363
364 out
365 }
366
367 #[cfg(test)]
368 mod tests {
369 use super::*;
370
371 // Minimal byte fixtures carrying each format's real magic bytes.
372 fn png_bytes() -> Vec<u8> {
373 let mut v = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
374 v.extend_from_slice(&[0u8; 64]);
375 v
376 }
377 fn jpeg_bytes() -> Vec<u8> {
378 let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0];
379 v.extend_from_slice(&[0u8; 64]);
380 v
381 }
382 fn gif_bytes() -> Vec<u8> {
383 let mut v = b"GIF89a".to_vec();
384 v.extend_from_slice(&[0u8; 64]);
385 v
386 }
387 fn webp_bytes() -> Vec<u8> {
388 let mut v = b"RIFF\x00\x00\x00\x00WEBP".to_vec();
389 v.extend_from_slice(&[0u8; 64]);
390 v
391 }
392
393 #[test]
394 fn validate_valid_png() {
395 let (ext, ct) = validate_image("photo.png", "image/png", &png_bytes()).unwrap();
396 assert_eq!(ext, "png");
397 assert_eq!(ct, "image/png");
398 }
399
400 #[test]
401 fn validate_valid_jpeg() {
402 let (ext, _) = validate_image("photo.jpg", "image/jpeg", &jpeg_bytes()).unwrap();
403 assert_eq!(ext, "jpg");
404 }
405
406 #[test]
407 fn validate_rejects_oversized() {
408 let mut big = png_bytes();
409 big.resize(6 * 1024 * 1024, 0);
410 let err = validate_image("big.png", "image/png", &big).unwrap_err();
411 assert!(err.contains("5 MB"));
412 }
413
414 #[test]
415 fn validate_rejects_bad_extension() {
416 let err = validate_image("file.exe", "application/octet-stream", &png_bytes()).unwrap_err();
417 assert!(err.contains("Allowed types"));
418 }
419
420 #[test]
421 fn validate_rejects_mismatched_type() {
422 let err = validate_image("photo.png", "image/jpeg", &png_bytes()).unwrap_err();
423 assert!(err.contains("does not match"));
424 }
425
426 #[test]
427 fn validate_rejects_png_pixel_bomb() {
428 // A tiny, valid-looking PNG that declares a 30000x30000 (900 MP) canvas.
429 let mut bomb = png_bytes();
430 let w = 30_000u32.to_be_bytes();
431 let h = 30_000u32.to_be_bytes();
432 bomb[16..20].copy_from_slice(&w);
433 bomb[20..24].copy_from_slice(&h);
434 let err = validate_image("bomb.png", "image/png", &bomb).unwrap_err();
435 assert!(err.contains("dimensions are too large"));
436 }
437
438 #[test]
439 fn validate_accepts_reasonable_png_dimensions() {
440 let mut img = png_bytes();
441 img[16..20].copy_from_slice(&1920u32.to_be_bytes());
442 img[20..24].copy_from_slice(&1080u32.to_be_bytes());
443 assert!(validate_image("ok.png", "image/png", &img).is_ok());
444 }
445
446 #[test]
447 fn validate_rejects_empty() {
448 let err = validate_image("photo.png", "image/png", &[]).unwrap_err();
449 assert!(err.contains("Empty"));
450 }
451
452 #[test]
453 fn validate_rejects_content_spoof() {
454 // Metadata says PNG and they agree, but the bytes are HTML, the classic
455 // polyglot/spoof the old metadata-only check waved through.
456 let html = b"<!DOCTYPE html><script>alert(1)</script>";
457 let err = validate_image("evil.png", "image/png", html).unwrap_err();
458 assert!(err.contains("not a valid"), "got: {err}");
459 }
460
461 #[test]
462 fn validate_rejects_format_mismatch_bytes() {
463 // Declared PNG (ext + content-type agree) but the bytes are a real JPEG.
464 let err = validate_image("photo.png", "image/png", &jpeg_bytes()).unwrap_err();
465 assert!(err.contains("do not match the declared"), "got: {err}");
466 }
467
468 #[test]
469 fn sniff_detects_each_format() {
470 assert_eq!(sniff_image_format(&png_bytes()), Some("png"));
471 assert_eq!(sniff_image_format(&jpeg_bytes()), Some("jpg"));
472 assert_eq!(sniff_image_format(&gif_bytes()), Some("gif"));
473 assert_eq!(sniff_image_format(&webp_bytes()), Some("webp"));
474 }
475
476 #[test]
477 fn sniff_rejects_non_image() {
478 assert_eq!(sniff_image_format(b"<svg xmlns=...>"), None);
479 assert_eq!(sniff_image_format(b""), None);
480 assert_eq!(sniff_image_format(b"RIFF\x00\x00\x00\x00AVI "), None); // RIFF but not WEBP
481 }
482
483 #[test]
484 fn validate_accepts_gif_and_webp() {
485 assert!(validate_image("a.gif", "image/gif", &gif_bytes()).is_ok());
486 assert!(validate_image("a.webp", "image/webp", &webp_bytes()).is_ok());
487 }
488
489 #[test]
490 fn strip_exif_preserves_non_jpeg() {
491 let data = b"not a jpeg";
492 let result = strip_exif_jpeg(data);
493 assert_eq!(result, data);
494 }
495
496 #[test]
497 fn strip_exif_minimal_jpeg() {
498 // Minimal JPEG: SOI + APP0 (JFIF) + SOS + EOI
499 let mut jpeg = vec![0xFF, 0xD8]; // SOI
500 // APP0 segment (marker + length + data)
501 jpeg.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]); // APP0, len=4
502 // APP1 segment (EXIF, should be stripped)
503 jpeg.extend_from_slice(&[0xFF, 0xE1, 0x00, 0x04, 0xAA, 0xBB]); // APP1, len=4
504 // SOS + fake data
505 jpeg.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x02]);
506 jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI
507
508 let result = strip_exif_jpeg(&jpeg);
509 // Should contain SOI, APP0, SOS+data, but NOT APP1
510 assert!(result.len() < jpeg.len(), "EXIF should be stripped");
511 let has_app1 = result.windows(2).any(|w| w == [0xFF, 0xE1]);
512 assert!(!has_app1, "APP1 should be removed");
513 let has_app0 = result.windows(2).any(|w| w == [0xFF, 0xE0]);
514 assert!(has_app0, "APP0 should be preserved");
515 }
516
517 #[test]
518 fn generate_key_format() {
519 let key = generate_image_key("test-comm", "png");
520 assert!(key.starts_with("mt/test-comm/"));
521 assert!(
522 std::path::Path::new(&key)
523 .extension()
524 .is_some_and(|ext| ext.eq_ignore_ascii_case("png"))
525 );
526 }
527
528 #[test]
529 fn allowed_extensions_match() {
530 // Verify our ALLOWED_EXTENSIONS list matches validate_image behavior
531 for ext in ALLOWED_EXTENSIONS {
532 let filename = format!("test.{ext}");
533 let (ct, bytes) = match *ext {
534 "png" => ("image/png", png_bytes()),
535 "jpg" | "jpeg" => ("image/jpeg", jpeg_bytes()),
536 "gif" => ("image/gif", gif_bytes()),
537 "webp" => ("image/webp", webp_bytes()),
538 _ => continue,
539 };
540 assert!(
541 validate_image(&filename, ct, &bytes).is_ok(),
542 "Extension {ext} should be valid"
543 );
544 }
545 }
546 }
547