1use crate::error::{Error, Result};
24use crate::request::Request;
25use bytes::Bytes;
26
27#[derive(Debug, Clone)]
29pub struct MultipartLimits {
30 pub max_parts: usize,
32 pub max_header_bytes: usize,
34}
35
36impl Default for MultipartLimits {
37 fn default() -> Self {
38 Self {
39 max_parts: 64,
40 max_header_bytes: 8 * 1024,
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct Part {
48 pub name: String,
50 pub filename: Option<String>,
52 pub content_type: Option<String>,
54 pub data: Bytes,
55}
56
57impl Part {
58 pub fn text(&self) -> Option<&str> {
60 std::str::from_utf8(&self.data).ok()
61 }
62
63 pub fn is_file(&self) -> bool {
64 self.filename.is_some()
65 }
66
67 pub fn safe_filename(&self) -> Option<String> {
73 sanitize_filename(self.filename.as_deref()?)
74 }
75}
76
77pub fn sanitize_filename(raw: &str) -> Option<String> {
79 let base = raw.rsplit(['/', '\\']).next().unwrap_or("");
81 let cleaned: String = base
82 .chars()
83 .map(|c| {
84 if c.is_control() || matches!(c, '"' | '<' | '>' | ':' | '|' | '?' | '*' | '\0') {
85 '_'
86 } else {
87 c
88 }
89 })
90 .collect();
91 let cleaned = cleaned.trim().trim_start_matches('.').trim();
92 let cleaned: String = cleaned.chars().take(120).collect();
93 let cleaned = cleaned.trim_end_matches(['.', ' ']).to_string();
94 (!cleaned.is_empty()).then_some(cleaned)
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct Multipart {
100 parts: Vec<Part>,
101}
102
103impl Multipart {
104 pub fn iter(&self) -> std::slice::Iter<'_, Part> {
105 self.parts.iter()
106 }
107
108 pub fn len(&self) -> usize {
109 self.parts.len()
110 }
111
112 pub fn is_empty(&self) -> bool {
113 self.parts.is_empty()
114 }
115
116 pub fn field(&self, name: &str) -> Option<&Part> {
118 self.parts.iter().find(|p| p.name == name)
119 }
120
121 pub fn text(&self, name: &str) -> Option<&str> {
123 self.parts
124 .iter()
125 .find(|p| p.name == name && !p.is_file())
126 .and_then(Part::text)
127 }
128
129 pub fn file(&self, name: &str) -> Option<&Part> {
131 self.parts.iter().find(|p| p.name == name && p.is_file())
132 }
133
134 pub fn files(&self) -> impl Iterator<Item = &Part> {
136 self.parts.iter().filter(|p| p.is_file())
137 }
138
139 pub fn parse(body: &Bytes, boundary: &str, limits: &MultipartLimits) -> Result<Self> {
141 parse(body, boundary, limits)
142 }
143}
144
145impl<'a> IntoIterator for &'a Multipart {
146 type Item = &'a Part;
147 type IntoIter = std::slice::Iter<'a, Part>;
148 fn into_iter(self) -> Self::IntoIter {
149 self.parts.iter()
150 }
151}
152
153impl Request {
154 pub fn multipart(&self) -> Result<Multipart> {
158 self.multipart_with(&MultipartLimits::default())
159 }
160
161 pub fn multipart_with(&self, limits: &MultipartLimits) -> Result<Multipart> {
162 let content_type = self
163 .headers
164 .get(http::header::CONTENT_TYPE)
165 .and_then(|v| v.to_str().ok())
166 .unwrap_or("");
167 let boundary = boundary_of(content_type)?;
168 Multipart::parse(&self.body, &boundary, limits)
169 }
170}
171
172fn boundary_of(content_type: &str) -> Result<String> {
173 let mut params = content_type.split(';');
174 let essence = params.next().unwrap_or("").trim();
175 if !essence.eq_ignore_ascii_case("multipart/form-data") {
176 return Err(Error::custom(
177 415,
178 "Expected content-type multipart/form-data",
179 ));
180 }
181 for p in params {
182 let Some((key, value)) = p.split_once('=') else {
183 continue;
184 };
185 if key.trim().eq_ignore_ascii_case("boundary") {
186 let value = value.trim().trim_matches('"');
187 let valid = !value.is_empty()
188 && value.len() <= 70
189 && value
190 .bytes()
191 .all(|b| b.is_ascii_alphanumeric() || b"'()+_,-./:=? ".contains(&b));
192 return if valid {
193 Ok(value.to_string())
194 } else {
195 Err(Error::bad_request("Invalid multipart boundary"))
196 };
197 }
198 }
199 Err(Error::bad_request("Missing multipart boundary"))
200}
201
202fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
203 if needle.is_empty() || from > haystack.len() {
204 return None;
205 }
206 haystack[from..]
207 .windows(needle.len())
208 .position(|w| w == needle)
209 .map(|i| i + from)
210}
211
212fn malformed(why: &str) -> Error {
213 Error::bad_request(format!("Malformed multipart body: {}", why))
214}
215
216fn parse(body: &Bytes, boundary: &str, limits: &MultipartLimits) -> Result<Multipart> {
217 let dash = format!("--{}", boundary).into_bytes();
218 let delim = [b"\r\n".as_slice(), &dash].concat();
220
221 let mut pos = if body.starts_with(&dash) {
223 dash.len()
224 } else {
225 find(body, &delim, 0).ok_or_else(|| malformed("no boundary found"))? + delim.len()
226 };
227
228 let mut parts = Vec::new();
229 loop {
230 if body[pos..].starts_with(b"--") {
232 return Ok(Multipart { parts });
233 }
234 while matches!(body.get(pos), Some(b' ' | b'\t')) {
235 pos += 1;
236 }
237 if !body[pos..].starts_with(b"\r\n") {
238 return Err(malformed("expected line break after boundary"));
239 }
240 pos += 2;
241
242 if parts.len() >= limits.max_parts {
243 return Err(Error::custom(
244 413,
245 format!("Too many multipart parts (max {})", limits.max_parts),
246 ));
247 }
248
249 let (headers_end, data_start) = if body[pos..].starts_with(b"\r\n") {
251 (pos, pos + 2)
252 } else {
253 let end = find(body, b"\r\n\r\n", pos)
254 .ok_or_else(|| malformed("unterminated part headers"))?;
255 (end, end + 4)
256 };
257 if headers_end - pos > limits.max_header_bytes {
258 return Err(Error::custom(
259 413,
260 format!(
261 "Multipart part headers larger than {} bytes",
262 limits.max_header_bytes
263 ),
264 ));
265 }
266 let header_text = std::str::from_utf8(&body[pos..headers_end])
267 .map_err(|_| malformed("part headers are not UTF-8"))?;
268
269 let data_end =
270 find(body, &delim, data_start).ok_or_else(|| malformed("unterminated part"))?;
271 let part = build_part(header_text, body.slice(data_start..data_end))?;
272 parts.push(part);
273 pos = data_end + delim.len();
274 }
275}
276
277fn build_part(header_text: &str, data: Bytes) -> Result<Part> {
278 let mut disposition = None;
279 let mut content_type = None;
280 for line in header_text.split("\r\n").filter(|l| !l.is_empty()) {
281 let (name, value) = line
282 .split_once(':')
283 .ok_or_else(|| malformed("bad part header"))?;
284 match name.trim().to_ascii_lowercase().as_str() {
285 "content-disposition" => disposition = Some(value.trim().to_string()),
286 "content-type" => content_type = Some(value.trim().to_string()),
287 _ => {}
288 }
289 }
290 let disposition = disposition.ok_or_else(|| malformed("part without content-disposition"))?;
291 let mut tokens = split_params(&disposition).into_iter();
292 if !tokens
293 .next()
294 .is_some_and(|(k, v)| v.is_none() && k.eq_ignore_ascii_case("form-data"))
295 {
296 return Err(malformed("part is not form-data"));
297 }
298 let (mut name, mut filename, mut filename_ext) = (None, None, None);
299 for (key, value) in tokens {
300 let Some(value) = value else { continue };
301 match key.to_ascii_lowercase().as_str() {
302 "name" => name = Some(value),
303 "filename" => filename = Some(value),
304 "filename*" => filename_ext = decode_ext_value(&value),
305 _ => {}
306 }
307 }
308 Ok(Part {
309 name: name.ok_or_else(|| malformed("part without a name"))?,
310 filename: filename_ext.or(filename),
312 content_type,
313 data,
314 })
315}
316
317fn split_params(s: &str) -> Vec<(String, Option<String>)> {
319 let mut out = Vec::new();
320 let mut chars = s.chars().peekable();
321 loop {
322 while matches!(chars.peek(), Some(c) if c.is_whitespace() || *c == ';') {
323 chars.next();
324 }
325 if chars.peek().is_none() {
326 return out;
327 }
328 let mut key = String::new();
329 while let Some(&c) = chars.peek() {
330 if c == '=' || c == ';' {
331 break;
332 }
333 key.push(c);
334 chars.next();
335 }
336 let key = key.trim().to_string();
337 if chars.peek() != Some(&'=') {
338 out.push((key, None));
339 continue;
340 }
341 chars.next(); let mut value = String::new();
343 if chars.peek() == Some(&'"') {
344 chars.next();
345 while let Some(c) = chars.next() {
346 match c {
347 '\\' => match chars.peek() {
351 Some('"') | Some('\\') => {
352 if let Some(escaped) = chars.next() {
353 value.push(escaped);
354 }
355 }
356 _ => value.push('\\'),
357 },
358 '"' => break,
359 c => value.push(c),
360 }
361 }
362 } else {
363 while let Some(&c) = chars.peek() {
364 if c == ';' {
365 break;
366 }
367 value.push(c);
368 chars.next();
369 }
370 value = value.trim().to_string();
371 }
372 out.push((key, Some(value)));
373 }
374}
375
376fn decode_ext_value(value: &str) -> Option<String> {
378 let rest = value
379 .strip_prefix("UTF-8''")
380 .or_else(|| value.strip_prefix("utf-8''"))?;
381 urlencoding::decode(rest).ok().map(|s| s.into_owned())
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 fn body(boundary: &str, parts: &[(&str, &[u8])]) -> Bytes {
389 let mut out = Vec::new();
390 for (headers, data) in parts {
391 out.extend_from_slice(format!("--{boundary}\r\n{headers}\r\n\r\n").as_bytes());
392 out.extend_from_slice(data);
393 out.extend_from_slice(b"\r\n");
394 }
395 out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
396 Bytes::from(out)
397 }
398
399 fn parse_ok(b: &Bytes, boundary: &str) -> Multipart {
400 parse(b, boundary, &MultipartLimits::default()).unwrap()
401 }
402
403 #[test]
404 fn fields_and_files_in_order() {
405 let b = body(
406 "XyZ",
407 &[
408 ("Content-Disposition: form-data; name=\"title\"", b"Hello \xC3\xA4"),
409 (
410 "Content-Disposition: form-data; name=\"doc\"; filename=\"a.txt\"\r\nContent-Type: text/plain",
411 b"line1\r\nline2",
412 ),
413 ("Content-Disposition: form-data; name=\"doc\"; filename=\"b.bin\"", &[0, 255, 13, 10, 0]),
414 ],
415 );
416 let form = parse_ok(&b, "XyZ");
417 assert_eq!(form.len(), 3);
418 assert_eq!(form.text("title"), Some("Hello ä"));
419 let a = form.file("doc").unwrap();
420 assert_eq!(
421 (a.filename.as_deref(), a.content_type.as_deref()),
422 (Some("a.txt"), Some("text/plain"))
423 );
424 assert_eq!(&a.data[..], b"line1\r\nline2");
425 let files: Vec<_> = form.files().map(|f| f.filename.clone().unwrap()).collect();
426 assert_eq!(files, ["a.txt", "b.bin"]);
427 assert_eq!(&form.files().nth(1).unwrap().data[..], &[0, 255, 13, 10, 0]);
429 assert_eq!(form.text("doc"), None);
431 assert!(form.field("missing").is_none());
432 }
433
434 #[test]
435 fn preamble_epilogue_and_empty_parts() {
436 let mut raw = b"ignored preamble\r\n".to_vec();
437 raw.extend_from_slice(&body(
438 "b",
439 &[("Content-Disposition: form-data; name=\"empty\"", b"")],
440 ));
441 raw.extend_from_slice(b"epilogue that is ignored");
442 let form = parse_ok(&Bytes::from(raw), "b");
443 assert_eq!(form.len(), 1);
444 assert_eq!(form.text("empty"), Some(""));
445
446 assert!(parse_ok(&Bytes::from_static(b"--b--\r\n"), "b").is_empty());
448 }
449
450 #[test]
451 fn data_that_looks_like_a_boundary_is_kept_when_not_a_full_delimiter() {
452 let tricky = b"--b not a delimiter because no CRLF before it, and --b-- too";
453 let b = body(
454 "b",
455 &[("Content-Disposition: form-data; name=\"x\"", tricky)],
456 );
457 assert_eq!(parse_ok(&b, "b").field("x").unwrap().data.as_ref(), tricky);
458 }
459
460 #[test]
461 fn quoted_params_escapes_and_extended_filenames() {
462 let b = body(
463 "b",
464 &[
465 ("Content-Disposition: form-data; name=\"we;ird \\\"name\\\"\"; filename=\"x;y.txt\"", b"1"),
466 (
467 "Content-Disposition: form-data; name=\"f\"; filename=\"fallback.txt\"; filename*=UTF-8''%E2%82%AC%20rates.txt",
468 b"2",
469 ),
470 ("content-disposition: FORM-DATA; NAME=unquoted", b"3"),
471 ],
472 );
473 let form = parse_ok(&b, "b");
474 let parts: Vec<_> = form.iter().collect();
475 assert_eq!(parts[0].name, "we;ird \"name\"");
476 assert_eq!(parts[0].filename.as_deref(), Some("x;y.txt"));
477 assert_eq!(parts[1].filename.as_deref(), Some("€ rates.txt"));
478 assert_eq!(parts[2].name, "unquoted");
479 }
480
481 #[test]
482 fn windows_paths_in_file_names_keep_their_backslashes() {
483 let b = body(
484 "b",
485 &[
486 ("Content-Disposition: form-data; name=\"f\"; filename=\"C:\\Users\\me\\evil.exe\"", b"1"),
487 ("Content-Disposition: form-data; name=\"g\"; filename=\"\\\\server\\share\\a.txt\"", b"2"),
488 ],
489 );
490 let form = parse_ok(&b, "b");
491 let parts: Vec<_> = form.iter().collect();
492 assert_eq!(
493 parts[0].filename.as_deref(),
494 Some("C:\\Users\\me\\evil.exe")
495 );
496 assert_eq!(parts[0].safe_filename().as_deref(), Some("evil.exe"));
497 assert_eq!(parts[1].safe_filename().as_deref(), Some("a.txt"));
498 }
499
500 #[test]
501 fn malformed_bodies_are_400() {
502 let l = MultipartLimits::default();
503 let cases: Vec<(&str, Bytes)> = vec![
504 ("no boundary", Bytes::from_static(b"just text")),
505 (
506 "no end",
507 Bytes::from_static(
508 b"--b\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\ndata",
509 ),
510 ),
511 (
512 "headers unterminated",
513 Bytes::from_static(b"--b\r\nContent-Disposition: form-data; name=\"a\""),
514 ),
515 ("garbage after boundary", Bytes::from_static(b"--bX\r\n")),
516 ("no disposition", body("b", &[("X-Other: 1", b"d")])),
517 (
518 "not form-data",
519 body(
520 "b",
521 &[("Content-Disposition: attachment; name=\"a\"", b"d")],
522 ),
523 ),
524 (
525 "no name",
526 body("b", &[("Content-Disposition: form-data", b"d")]),
527 ),
528 (
529 "bad header line",
530 body("b", &[("Content-Disposition form-data", b"d")]),
531 ),
532 ];
533 for (why, b) in cases {
534 let err = parse(&b, "b", &l).unwrap_err();
535 assert_eq!(err.status_code(), 400, "{why}: {}", err.message());
536 }
537 }
538
539 #[test]
540 fn limits_answer_413() {
541 let many: Vec<(&str, &[u8])> = (0..5)
542 .map(|_| {
543 (
544 "Content-Disposition: form-data; name=\"a\"",
545 b"x".as_slice(),
546 )
547 })
548 .collect();
549 let b = body("b", &many);
550 let few = MultipartLimits {
551 max_parts: 4,
552 ..Default::default()
553 };
554 assert_eq!(parse(&b, "b", &few).unwrap_err().status_code(), 413);
555 assert!(parse(
556 &b,
557 "b",
558 &MultipartLimits {
559 max_parts: 5,
560 ..Default::default()
561 }
562 )
563 .is_ok());
564
565 let long = format!(
566 "Content-Disposition: form-data; name=\"a\"\r\nX-Pad: {}",
567 "p".repeat(300)
568 );
569 let b = body("b", &[(long.as_str(), b"x")]);
570 let tight = MultipartLimits {
571 max_header_bytes: 100,
572 ..Default::default()
573 };
574 assert_eq!(parse(&b, "b", &tight).unwrap_err().status_code(), 413);
575 }
576
577 #[test]
578 fn boundary_from_content_type() {
579 assert_eq!(
580 boundary_of("multipart/form-data; boundary=abc").unwrap(),
581 "abc"
582 );
583 assert_eq!(
584 boundary_of("Multipart/Form-Data; charset=utf-8; BOUNDARY=\"a b\"").unwrap(),
585 "a b"
586 );
587 assert_eq!(
588 boundary_of("application/json").unwrap_err().status_code(),
589 415
590 );
591 assert_eq!(boundary_of("").unwrap_err().status_code(), 415);
592 assert_eq!(
593 boundary_of("multipart/form-data")
594 .unwrap_err()
595 .status_code(),
596 400
597 );
598 assert_eq!(
599 boundary_of("multipart/form-data; boundary=")
600 .unwrap_err()
601 .status_code(),
602 400
603 );
604 assert_eq!(
605 boundary_of(&format!("multipart/form-data; boundary={}", "x".repeat(71)))
606 .unwrap_err()
607 .status_code(),
608 400
609 );
610 assert_eq!(
611 boundary_of("multipart/form-data; boundary=a\"b<c")
612 .unwrap_err()
613 .status_code(),
614 400
615 );
616 }
617
618 #[test]
619 fn filenames_are_sanitized() {
620 let s = |x: &str| sanitize_filename(x);
621 assert_eq!(s("report.pdf").as_deref(), Some("report.pdf"));
622 assert_eq!(s("../../etc/passwd").as_deref(), Some("passwd"));
623 assert_eq!(s("C:\\Users\\me\\evil.exe").as_deref(), Some("evil.exe"));
624 assert_eq!(s("..hidden").as_deref(), Some("hidden"));
625 assert_eq!(s(".htaccess").as_deref(), Some("htaccess"));
626 assert_eq!(
627 s("a<b>:c|d?e*\"f.txt").as_deref(),
628 Some("a_b__c_d_e__f.txt")
629 );
630 assert_eq!(s("tab\there\nnl.txt").as_deref(), Some("tab_here_nl.txt"));
631 assert_eq!(s("ünïcödé 日本.txt").as_deref(), Some("ünïcödé 日本.txt"));
632 assert_eq!(s("name. . ").as_deref(), Some("name"));
633 assert_eq!(s(&"a".repeat(300)).unwrap().len(), 120);
634 for empty in ["", " ", "..", "/", "a/", "../", ". . ."] {
635 assert_eq!(s(empty), None, "{empty:?}");
636 }
637 }
638
639 #[test]
640 fn request_helper_checks_the_content_type() {
641 let b = body(
642 "zz",
643 &[("Content-Disposition: form-data; name=\"a\"", b"1")],
644 );
645 let mut headers = http::HeaderMap::new();
646 headers.insert(
647 http::header::CONTENT_TYPE,
648 "multipart/form-data; boundary=zz".parse().unwrap(),
649 );
650 let req = Request::new(http::Method::POST, "/".parse().unwrap(), headers, b.clone());
651 assert_eq!(req.multipart().unwrap().text("a"), Some("1"));
652
653 let plain = Request::new(
654 http::Method::POST,
655 "/".parse().unwrap(),
656 http::HeaderMap::new(),
657 b,
658 );
659 assert_eq!(plain.multipart().unwrap_err().status_code(), 415);
660 }
661}