| 75 |
75 |
|
format!("mt/{community_slug}/{id}.{ext}")
|
| 76 |
76 |
|
}
|
| 77 |
77 |
|
|
|
78 |
+ |
/// Sniff the real image format from leading magic bytes, independent of the
|
|
79 |
+ |
/// client-declared filename or Content-Type. Returns the canonical extension
|
|
80 |
+ |
/// family (`png`/`jpg`/`gif`/`webp`) or `None` if the bytes are not one of the
|
|
81 |
+ |
/// four allowed image formats. This is the only attacker-uncontrollable signal
|
|
82 |
+ |
/// in the upload — the filename extension and the multipart Content-Type are
|
|
83 |
+ |
/// both client-supplied, so a content check that only cross-references those
|
|
84 |
+ |
/// two (as the old validator did) is trivially satisfied by a spoofed file.
|
|
85 |
+ |
pub fn sniff_image_format(data: &[u8]) -> Option<&'static str> {
|
|
86 |
+ |
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
87 |
+ |
if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
|
|
88 |
+ |
return Some("png");
|
|
89 |
+ |
}
|
|
90 |
+ |
// JPEG: starts with FF D8 FF
|
|
91 |
+ |
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
|
92 |
+ |
return Some("jpg");
|
|
93 |
+ |
}
|
|
94 |
+ |
// GIF: "GIF87a" or "GIF89a"
|
|
95 |
+ |
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
|
|
96 |
+ |
return Some("gif");
|
|
97 |
+ |
}
|
|
98 |
+ |
// WebP: "RIFF" .... "WEBP" (bytes 0-3 = RIFF, 8-11 = WEBP)
|
|
99 |
+ |
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
|
|
100 |
+ |
return Some("webp");
|
|
101 |
+ |
}
|
|
102 |
+ |
None
|
|
103 |
+ |
}
|
|
104 |
+ |
|
| 78 |
105 |
|
/// Validate an uploaded file. Returns the sanitized extension and content type.
|
|
106 |
+ |
///
|
|
107 |
+ |
/// Defence in depth: the filename extension and the multipart Content-Type must
|
|
108 |
+ |
/// agree AND the actual file bytes must sniff to the same image format. A
|
|
109 |
+ |
/// polyglot or content-spoofed file (HTML/SVG bytes named `evil.png` with a
|
|
110 |
+ |
/// faked `image/png` Content-Type) fails the byte sniff even though it satisfies
|
|
111 |
+ |
/// the metadata cross-check.
|
| 79 |
112 |
|
pub fn validate_image(
|
| 80 |
113 |
|
filename: &str,
|
| 81 |
114 |
|
content_type: &str,
|
| 82 |
|
- |
size: usize,
|
|
115 |
+ |
data: &[u8],
|
| 83 |
116 |
|
) -> Result<(&'static str, &'static str), &'static str> {
|
|
117 |
+ |
let size = data.len();
|
| 84 |
118 |
|
if size > MAX_IMAGE_SIZE {
|
| 85 |
119 |
|
return Err("Image exceeds 5 MB limit.");
|
| 86 |
120 |
|
}
|
| 122 |
156 |
|
return Err("File extension does not match content type.");
|
| 123 |
157 |
|
}
|
| 124 |
158 |
|
|
| 125 |
|
- |
Ok((ext_str, ct))
|
|
159 |
+ |
// Authoritative check: the bytes themselves must be the declared format.
|
|
160 |
+ |
match sniff_image_format(data) {
|
|
161 |
+ |
Some(sniffed) if sniffed == ext_str => Ok((ext_str, ct)),
|
|
162 |
+ |
Some(_) => Err("File contents do not match the declared image type."),
|
|
163 |
+ |
None => Err("File is not a valid PNG, JPEG, GIF, or WebP image."),
|
|
164 |
+ |
}
|
| 126 |
165 |
|
}
|
| 127 |
166 |
|
|
| 128 |
167 |
|
/// Strip EXIF metadata from JPEG data.
|
| 189 |
228 |
|
mod tests {
|
| 190 |
229 |
|
use super::*;
|
| 191 |
230 |
|
|
|
231 |
+ |
// Minimal byte fixtures carrying each format's real magic bytes.
|
|
232 |
+ |
fn png_bytes() -> Vec<u8> {
|
|
233 |
+ |
let mut v = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
|
234 |
+ |
v.extend_from_slice(&[0u8; 64]);
|
|
235 |
+ |
v
|
|
236 |
+ |
}
|
|
237 |
+ |
fn jpeg_bytes() -> Vec<u8> {
|
|
238 |
+ |
let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0];
|
|
239 |
+ |
v.extend_from_slice(&[0u8; 64]);
|
|
240 |
+ |
v
|
|
241 |
+ |
}
|
|
242 |
+ |
fn gif_bytes() -> Vec<u8> {
|
|
243 |
+ |
let mut v = b"GIF89a".to_vec();
|
|
244 |
+ |
v.extend_from_slice(&[0u8; 64]);
|
|
245 |
+ |
v
|
|
246 |
+ |
}
|
|
247 |
+ |
fn webp_bytes() -> Vec<u8> {
|
|
248 |
+ |
let mut v = b"RIFF\x00\x00\x00\x00WEBP".to_vec();
|
|
249 |
+ |
v.extend_from_slice(&[0u8; 64]);
|
|
250 |
+ |
v
|
|
251 |
+ |
}
|
|
252 |
+ |
|
| 192 |
253 |
|
#[test]
|
| 193 |
254 |
|
fn validate_valid_png() {
|
| 194 |
|
- |
let (ext, ct) = validate_image("photo.png", "image/png", 1024).unwrap();
|
|
255 |
+ |
let (ext, ct) = validate_image("photo.png", "image/png", &png_bytes()).unwrap();
|
| 195 |
256 |
|
assert_eq!(ext, "png");
|
| 196 |
257 |
|
assert_eq!(ct, "image/png");
|
| 197 |
258 |
|
}
|
| 198 |
259 |
|
|
| 199 |
260 |
|
#[test]
|
| 200 |
261 |
|
fn validate_valid_jpeg() {
|
| 201 |
|
- |
let (ext, _) = validate_image("photo.jpg", "image/jpeg", 1024).unwrap();
|
|
262 |
+ |
let (ext, _) = validate_image("photo.jpg", "image/jpeg", &jpeg_bytes()).unwrap();
|
| 202 |
263 |
|
assert_eq!(ext, "jpg");
|
| 203 |
264 |
|
}
|
| 204 |
265 |
|
|
| 205 |
266 |
|
#[test]
|
| 206 |
267 |
|
fn validate_rejects_oversized() {
|
| 207 |
|
- |
let err = validate_image("big.png", "image/png", 6 * 1024 * 1024).unwrap_err();
|
|
268 |
+ |
let mut big = png_bytes();
|
|
269 |
+ |
big.resize(6 * 1024 * 1024, 0);
|
|
270 |
+ |
let err = validate_image("big.png", "image/png", &big).unwrap_err();
|
| 208 |
271 |
|
assert!(err.contains("5 MB"));
|
| 209 |
272 |
|
}
|
| 210 |
273 |
|
|
| 211 |
274 |
|
#[test]
|
| 212 |
275 |
|
fn validate_rejects_bad_extension() {
|
| 213 |
|
- |
let err = validate_image("file.exe", "application/octet-stream", 1024).unwrap_err();
|
|
276 |
+ |
let err = validate_image("file.exe", "application/octet-stream", &png_bytes()).unwrap_err();
|
| 214 |
277 |
|
assert!(err.contains("Allowed types"));
|
| 215 |
278 |
|
}
|
| 216 |
279 |
|
|
| 217 |
280 |
|
#[test]
|
| 218 |
281 |
|
fn validate_rejects_mismatched_type() {
|
| 219 |
|
- |
let err = validate_image("photo.png", "image/jpeg", 1024).unwrap_err();
|
|
282 |
+ |
let err = validate_image("photo.png", "image/jpeg", &png_bytes()).unwrap_err();
|
| 220 |
283 |
|
assert!(err.contains("does not match"));
|
| 221 |
284 |
|
}
|
| 222 |
285 |
|
|
| 223 |
286 |
|
#[test]
|
| 224 |
287 |
|
fn validate_rejects_empty() {
|
| 225 |
|
- |
let err = validate_image("photo.png", "image/png", 0).unwrap_err();
|
|
288 |
+ |
let err = validate_image("photo.png", "image/png", &[]).unwrap_err();
|
| 226 |
289 |
|
assert!(err.contains("Empty"));
|
| 227 |
290 |
|
}
|
| 228 |
291 |
|
|
| 229 |
292 |
|
#[test]
|
|
293 |
+ |
fn validate_rejects_content_spoof() {
|
|
294 |
+ |
// Metadata says PNG and they agree, but the bytes are HTML — the classic
|
|
295 |
+ |
// polyglot/spoof the old metadata-only check waved through.
|
|
296 |
+ |
let html = b"<!DOCTYPE html><script>alert(1)</script>";
|
|
297 |
+ |
let err = validate_image("evil.png", "image/png", html).unwrap_err();
|
|
298 |
+ |
assert!(err.contains("not a valid"), "got: {err}");
|
|
299 |
+ |
}
|
|
300 |
+ |
|
|
301 |
+ |
#[test]
|
|
302 |
+ |
fn validate_rejects_format_mismatch_bytes() {
|
|
303 |
+ |
// Declared PNG (ext + content-type agree) but the bytes are a real JPEG.
|
|
304 |
+ |
let err = validate_image("photo.png", "image/png", &jpeg_bytes()).unwrap_err();
|
|
305 |
+ |
assert!(err.contains("do not match the declared"), "got: {err}");
|
|
306 |
+ |
}
|
|
307 |
+ |
|
|
308 |
+ |
#[test]
|
|
309 |
+ |
fn sniff_detects_each_format() {
|
|
310 |
+ |
assert_eq!(sniff_image_format(&png_bytes()), Some("png"));
|
|
311 |
+ |
assert_eq!(sniff_image_format(&jpeg_bytes()), Some("jpg"));
|
|
312 |
+ |
assert_eq!(sniff_image_format(&gif_bytes()), Some("gif"));
|
|
313 |
+ |
assert_eq!(sniff_image_format(&webp_bytes()), Some("webp"));
|
|
314 |
+ |
}
|
|
315 |
+ |
|
|
316 |
+ |
#[test]
|
|
317 |
+ |
fn sniff_rejects_non_image() {
|
|
318 |
+ |
assert_eq!(sniff_image_format(b"<svg xmlns=...>"), None);
|
|
319 |
+ |
assert_eq!(sniff_image_format(b""), None);
|
|
320 |
+ |
assert_eq!(sniff_image_format(b"RIFF\x00\x00\x00\x00AVI "), None); // RIFF but not WEBP
|
|
321 |
+ |
}
|
|
322 |
+ |
|
|
323 |
+ |
#[test]
|
|
324 |
+ |
fn validate_accepts_gif_and_webp() {
|
|
325 |
+ |
assert!(validate_image("a.gif", "image/gif", &gif_bytes()).is_ok());
|
|
326 |
+ |
assert!(validate_image("a.webp", "image/webp", &webp_bytes()).is_ok());
|
|
327 |
+ |
}
|
|
328 |
+ |
|
|
329 |
+ |
#[test]
|
| 230 |
330 |
|
fn strip_exif_preserves_non_jpeg() {
|
| 231 |
331 |
|
let data = b"not a jpeg";
|
| 232 |
332 |
|
let result = strip_exif_jpeg(data);
|
| 268 |
368 |
|
// Verify our ALLOWED_EXTENSIONS list matches validate_image behavior
|
| 269 |
369 |
|
for ext in ALLOWED_EXTENSIONS {
|
| 270 |
370 |
|
let filename = format!("test.{ext}");
|
| 271 |
|
- |
let ct = match *ext {
|
| 272 |
|
- |
"png" => "image/png",
|
| 273 |
|
- |
"jpg" | "jpeg" => "image/jpeg",
|
| 274 |
|
- |
"gif" => "image/gif",
|
| 275 |
|
- |
"webp" => "image/webp",
|
|
371 |
+ |
let (ct, bytes) = match *ext {
|
|
372 |
+ |
"png" => ("image/png", png_bytes()),
|
|
373 |
+ |
"jpg" | "jpeg" => ("image/jpeg", jpeg_bytes()),
|
|
374 |
+ |
"gif" => ("image/gif", gif_bytes()),
|
|
375 |
+ |
"webp" => ("image/webp", webp_bytes()),
|
| 276 |
376 |
|
_ => continue,
|
| 277 |
377 |
|
};
|
| 278 |
|
- |
assert!(validate_image(&filename, ct, 1024).is_ok(), "Extension {ext} should be valid");
|
|
378 |
+ |
assert!(validate_image(&filename, ct, &bytes).is_ok(), "Extension {ext} should be valid");
|
| 279 |
379 |
|
}
|
| 280 |
380 |
|
}
|
| 281 |
381 |
|
}
|