Skip to main content

ullun/
multipart.rs

1//! `multipart/form-data` request bodies (file uploads and mixed forms)
2//!
3//! ```
4//! use ullun::prelude::*;
5//!
6//! async fn upload(req: Request) -> Result<Response> {
7//!     let form = req.multipart()?;
8//!     let title = form.text("title").unwrap_or("untitled");
9//!     let Some(file) = form.file("avatar") else {
10//!         return Err(Error::bad_request("avatar is required"));
11//!     };
12//!     // never use the client's file name as a path: sanitize it, and store
13//!     // the bytes under a name you generated
14//!     let name = file.safe_filename().unwrap_or_else(|| "upload".into());
15//!     Ok(Response::json(serde_json::json!({ "title": title, "name": name, "bytes": file.data.len() })))
16//! }
17//! ```
18//!
19//! The whole body is buffered, so it is bounded by the request body limit:
20//! raise it for upload routes with `App::body_limit` / `RouteGroup::max_body_size`
21//! and keep it modest. Parsing itself is limited by [`MultipartLimits`].
22
23use crate::error::{Error, Result};
24use crate::request::Request;
25use bytes::Bytes;
26
27/// Bounds against hostile bodies
28#[derive(Debug, Clone)]
29pub struct MultipartLimits {
30    /// Parts accepted in one body (default 64)
31    pub max_parts: usize,
32    /// Bytes of headers per part (default 8 KiB)
33    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/// One field or file of the form
46#[derive(Debug, Clone)]
47pub struct Part {
48    /// The form field name
49    pub name: String,
50    /// Set for file inputs; chosen by the client, so untrusted
51    pub filename: Option<String>,
52    /// The part's `Content-Type`, if sent; chosen by the client, so untrusted
53    pub content_type: Option<String>,
54    pub data: Bytes,
55}
56
57impl Part {
58    /// The data as text, if it is valid UTF-8
59    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    /// The client's file name reduced to something safe to display or use as a
68    /// download name: no directories, no control or special characters, no
69    /// leading dots, at most 120 characters. `None` if nothing is left.
70    ///
71    /// Do not use it to build paths for storing the upload: generate your own name.
72    pub fn safe_filename(&self) -> Option<String> {
73        sanitize_filename(self.filename.as_deref()?)
74    }
75}
76
77/// See [`Part::safe_filename`]
78pub fn sanitize_filename(raw: &str) -> Option<String> {
79    // the last path component, whichever separator the client's OS uses
80    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/// The parts of a `multipart/form-data` body, in order
98#[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    /// First part with this field name
117    pub fn field(&self, name: &str) -> Option<&Part> {
118        self.parts.iter().find(|p| p.name == name)
119    }
120
121    /// First non-file field as text
122    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    /// First file with this field name
130    pub fn file(&self, name: &str) -> Option<&Part> {
131        self.parts.iter().find(|p| p.name == name && p.is_file())
132    }
133
134    /// All files, in order (for `<input type=file multiple>`)
135    pub fn files(&self) -> impl Iterator<Item = &Part> {
136        self.parts.iter().filter(|p| p.is_file())
137    }
138
139    /// Parse `body` with the given boundary
140    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    /// Parse a `multipart/form-data` body with the default [`MultipartLimits`]
155    ///
156    /// `415` for other content types, `400` for malformed bodies.
157    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    // every later delimiter is preceded by the CRLF that ends the previous part
219    let delim = [b"\r\n".as_slice(), &dash].concat();
220
221    // preamble (ignored), then the first delimiter at the start or after a CRLF
222    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        // after a delimiter: `--` ends the body, otherwise (transport padding and) CRLF
231        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        // headers end at the first empty line (a part without headers starts with CRLF)
250        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        // RFC 6266: the extended form wins when both are present
311        filename: filename_ext.or(filename),
312        content_type,
313        data,
314    })
315}
316
317/// `form-data; name="a b"; filename="x;y.txt"` -> [("form-data", None), ("name", Some("a b")), ...]
318fn 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(); // '='
342        let mut value = String::new();
343        if chars.peek() == Some(&'"') {
344            chars.next();
345            while let Some(c) = chars.next() {
346                match c {
347                    // Only \" and \\ are escapes. Browsers do not escape backslashes
348                    // and old ones send whole Windows paths (C:\dir\file.txt), which
349                    // must survive so that the directory part can be stripped later.
350                    '\\' => 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
376/// RFC 5987 `UTF-8''%E2%82%AC%20rates.txt`
377fn 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        // binary content with CRLF and NUL survives
428        assert_eq!(&form.files().nth(1).unwrap().data[..], &[0, 255, 13, 10, 0]);
429        // a file is not a text field
430        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        // an empty form is valid
447        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}