Skip to main content

form_net/
kind.rs

1//! Form kind detection from the request's `Content-Type`, and the parameter
2//! rules shared with part headers.
3
4use parse_net::{DuplicatePolicy, Params, Word};
5
6use crate::error::{EncodingError, FormError, Location, SyntaxError};
7use crate::multipart::boundary::Boundary;
8
9/// parse-net limits for form headers: at most 16 parameters, and repeated
10/// names reported to form-net, which applies its own repeat rules (D-063).
11pub(crate) const HEADER_LIMITS: parse_net::Limits = parse_net::Limits::DEFAULT
12    .max_params(16)
13    .duplicate_params(DuplicatePolicy::First);
14
15/// The name of the first parameter whose name was already used, compared
16/// ASCII case-insensitively. Quadratic in the parameter count, which
17/// `HEADER_LIMITS` caps at 16.
18pub(crate) fn repeated_name<'a>(params: &Params<'a>) -> Option<&'a [u8]> {
19    for (i, p) in params.iter().enumerate() {
20        if params
21            .iter()
22            .take(i)
23            .any(|q| q.name.eq_ignore_ascii_case(p.name))
24        {
25            return Some(p.name);
26        }
27    }
28    None
29}
30
31/// Whether a header value has an empty parameter: `;` then optional
32/// whitespace then `;`, outside a quoted string. RFC 9110 allows it; Go's
33/// `mime.ParseMediaType` refuses the whole parameter list, so a Go reader in
34/// front sees no boundary, or a part with no name, where form-net reads one
35/// (D-136). A single trailing `;` is read the same by both and stays.
36pub(crate) fn has_empty_parameter(s: &[u8]) -> bool {
37    // Two `;` at least, or there is nothing to find: a browser's header has
38    // one or two, and the count is a single fast pass.
39    if s.iter().filter(|&&b| b == b';').count() < 2 {
40        return false;
41    }
42    let mut quoted = false;
43    let mut escaped = false;
44    let mut after_semicolon = false;
45    for &b in s {
46        if quoted {
47            if escaped {
48                escaped = false;
49            } else if b == b'\\' {
50                escaped = true;
51            } else if b == b'"' {
52                quoted = false;
53            }
54            continue;
55        }
56        match b {
57            b';' if after_semicolon => return true,
58            b';' => after_semicolon = true,
59            b' ' | b'\t' => {}
60            b'"' => {
61                quoted = true;
62                after_semicolon = false;
63            }
64            _ => after_semicolon = false,
65        }
66    }
67    false
68}
69
70/// Whether a parameter value holds `name=` or `filename=`, in any ASCII
71/// case, where a reader that splits the header on `;` or whitespace would
72/// take it for a parameter: at the start of the value or after `;`, `,`,
73/// SP, or HTAB. multer 3.1 (the reader behind axum) reads another field
74/// name there, or a file where form-net reads a field (D-136). After a
75/// letter it does not, so a file name like `Surname=Smith.pdf` is accepted
76/// (D-152, checked by running multer).
77pub(crate) fn holds_name_assignment(value: &[u8]) -> bool {
78    if !value.contains(&b'=') {
79        return false;
80    }
81    let at = |i: usize| {
82        let rest = value.get(i..).unwrap_or(&[]);
83        let starts = |p: &[u8]| {
84            rest.get(..p.len())
85                .is_some_and(|r| r.eq_ignore_ascii_case(p))
86        };
87        starts(b"name=") || starts(b"filename=")
88    };
89    if at(0) {
90        return true;
91    }
92    value
93        .iter()
94        .enumerate()
95        .any(|(i, &b)| matches!(b, b';' | b',' | b' ' | b'\t') && at(i.wrapping_add(1)))
96}
97
98/// The bytes of a parameter value as sent: a token, or the content of a
99/// quoted string with backslashes kept literal.
100pub(crate) const fn word_raw<'a>(w: &Word<'a>) -> &'a [u8] {
101    match w {
102        Word::Token(t) => t,
103        Word::Quoted(q) => q.raw(),
104    }
105}
106
107/// Which form encoding a request body uses.
108///
109/// # Examples
110///
111/// ```
112/// use form_net::{FormError, FormKind};
113///
114/// match FormKind::detect(b"multipart/form-data; boundary=----WebKitFormBoundaryx7Y")? {
115///     FormKind::Urlencoded => { /* parse_urlencoded */ }
116///     FormKind::Multipart(boundary) => assert_eq!(boundary.as_bytes(), b"----WebKitFormBoundaryx7Y"),
117///     _ => return Err(FormError::UnsupportedMediaType),
118/// }
119/// assert_eq!(FormKind::detect(b"Application/X-WWW-Form-Urlencoded; charset=UTF-8")?, FormKind::Urlencoded);
120/// assert_eq!(FormKind::detect(b"text/plain"), Err(FormError::UnsupportedMediaType));
121/// # Ok::<(), FormError>(())
122/// ```
123#[non_exhaustive]
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum FormKind {
126    /// `application/x-www-form-urlencoded`.
127    Urlencoded,
128    /// `multipart/form-data`, with its validated boundary.
129    Multipart(Boundary),
130}
131
132impl FormKind {
133    /// Reads a request's `Content-Type` value. A pure function of the header
134    /// bytes; it never looks at the body.
135    ///
136    /// `application/x-www-form-urlencoded` is accepted with any parameters
137    /// except a `charset` other than UTF-8. `multipart/form-data`
138    /// needs exactly one valid `boundary`. Everything else, including
139    /// `text/plain`, is `UnsupportedMediaType`. A repeated parameter, an
140    /// empty parameter (`;;`), and an extended parameter (`boundary*`,
141    /// `charset*0`) are refused (D-136).
142    ///
143    /// # Errors
144    ///
145    /// `UnsupportedMediaType`; `Syntax { InvalidContentType | MissingBoundary
146    /// | DuplicateBoundary | InvalidBoundary }`; `Encoding { UnsupportedCharset }`.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use form_net::{FormError, FormKind, SyntaxError};
152    ///
153    /// assert!(matches!(
154    ///     FormKind::detect(b"multipart/form-data; boundary=a; boundary=b"),
155    ///     Err(FormError::Syntax { reason: SyntaxError::DuplicateBoundary, .. })
156    /// ));
157    /// ```
158    pub fn detect(content_type: &[u8]) -> Result<FormKind, FormError> {
159        if parse_net::text::trim_ows(content_type).is_empty() {
160            return Err(FormError::UnsupportedMediaType);
161        }
162        let mt = parse_net::media::parse(content_type, &HEADER_LIMITS).map_err(|e| {
163            FormError::syntax(
164                SyntaxError::InvalidContentType,
165                Location::at(e.offset() as u64),
166            )
167        })?;
168        let urlencoded = mt.is_form_urlencoded();
169        if !urlencoded && !mt.is_multipart_form_data() {
170            return Err(FormError::UnsupportedMediaType);
171        }
172        let params = mt.params();
173        if has_empty_parameter(content_type) {
174            return Err(FormError::syntax(
175                SyntaxError::InvalidContentType,
176                Location::at(0),
177            ));
178        }
179        // `*` is in no browser's header: the fast byte search keeps the
180        // parameter walk off the common path.
181        if let Some(p) = content_type
182            .contains(&b'*')
183            .then(|| params.iter().find(|p| p.name.contains(&b'*')))
184            .flatten()
185        {
186            // `boundary*=UTF-8''Y` beside `boundary=X`: Go's
187            // `mime.ParseMediaType` frames the body with Y, form-net would
188            // with X (D-136). The same reasoning as D-081 for part headers.
189            let reason = if p.name.len() >= 8
190                && p.name
191                    .get(..8)
192                    .is_some_and(|n| n.eq_ignore_ascii_case(b"boundary"))
193            {
194                SyntaxError::InvalidBoundary
195            } else {
196                SyntaxError::InvalidContentType
197            };
198            return Err(FormError::syntax(reason, Location::at(0)));
199        }
200        if let Some(name) = repeated_name(&params) {
201            let reason = if name.eq_ignore_ascii_case(b"boundary") {
202                SyntaxError::DuplicateBoundary
203            } else {
204                SyntaxError::InvalidContentType
205            };
206            return Err(FormError::syntax(reason, Location::at(0)));
207        }
208        if urlencoded {
209            if let Some(cs) = params.get("charset") {
210                // UTF-8 only: the urlencoded parser does not see this label,
211                // and WHATWG readers decode `us-ascii` as windows-1252, so a
212                // `%E9` would read two ways (D-150).
213                if !cs.eq_ignore_ascii_case(b"utf-8") {
214                    return Err(FormError::encoding(
215                        EncodingError::UnsupportedCharset,
216                        Location::at(0),
217                    ));
218                }
219            }
220            return Ok(FormKind::Urlencoded);
221        }
222        let Some(word) = params.get("boundary") else {
223            return Err(FormError::syntax(
224                SyntaxError::MissingBoundary,
225                Location::at(0),
226            ));
227        };
228        if let Word::Quoted(q) = word {
229            if q.has_escapes() {
230                return Err(FormError::syntax(
231                    SyntaxError::InvalidBoundary,
232                    Location::at(0),
233                ));
234            }
235        }
236        Boundary::new(word_raw(&word)).map(FormKind::Multipart)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::error::Limit;
244
245    fn reason(ct: &[u8]) -> Option<SyntaxError> {
246        match FormKind::detect(ct) {
247            Err(FormError::Syntax { reason, .. }) => Some(reason),
248            _ => None,
249        }
250    }
251
252    #[test]
253    fn test_detect_urlencoded_cases() {
254        for ct in [
255            &b"application/x-www-form-urlencoded"[..],
256            b"APPLICATION/X-WWW-FORM-URLENCODED",
257            b"application/x-www-form-urlencoded; charset=UTF-8",
258            b"application/x-www-form-urlencoded; charset=\"utf-8\"",
259            b"application/x-www-form-urlencoded; charset=utf-8; other=1",
260            b"  application/x-www-form-urlencoded  ",
261        ] {
262            assert_eq!(FormKind::detect(ct), Ok(FormKind::Urlencoded), "{ct:?}");
263        }
264        assert!(matches!(
265            FormKind::detect(b"application/x-www-form-urlencoded; charset=iso-8859-1"),
266            Err(FormError::Encoding {
267                reason: EncodingError::UnsupportedCharset,
268                ..
269            })
270        ));
271        assert_eq!(
272            reason(b"application/x-www-form-urlencoded; charset=utf-8; Charset=utf-8"),
273            Some(SyntaxError::InvalidContentType)
274        );
275    }
276
277    #[test]
278    fn test_detect_multipart_boundary_rules() {
279        assert!(matches!(
280            FormKind::detect(b"multipart/form-data; boundary=x"),
281            Ok(FormKind::Multipart(_))
282        ));
283        assert!(matches!(
284            FormKind::detect(b"Multipart/Form-Data; Boundary=\"a b\""),
285            Ok(FormKind::Multipart(b)) if b.as_bytes() == b"a b"
286        ));
287        assert_eq!(
288            reason(b"multipart/form-data"),
289            Some(SyntaxError::MissingBoundary)
290        );
291        assert_eq!(
292            reason(b"multipart/form-data; boundary=a; boundary=a"),
293            Some(SyntaxError::DuplicateBoundary)
294        );
295        assert_eq!(
296            reason(b"multipart/form-data; boundary=\"\""),
297            Some(SyntaxError::InvalidBoundary)
298        );
299        assert_eq!(
300            reason(b"multipart/form-data; boundary=\"ab \""),
301            Some(SyntaxError::InvalidBoundary)
302        );
303        assert_eq!(
304            reason(b"multipart/form-data; boundary=\"a\\b\""),
305            Some(SyntaxError::InvalidBoundary)
306        );
307        assert_eq!(
308            reason(b"multipart/form-data; boundary="),
309            Some(SyntaxError::InvalidContentType)
310        );
311        assert_eq!(
312            reason(b"multipart/form-data; boundary=a; x=1; X=2"),
313            Some(SyntaxError::InvalidContentType)
314        );
315        assert_eq!(
316            reason(b"multipart/form-data; boundary=x junk"),
317            Some(SyntaxError::InvalidContentType)
318        );
319    }
320
321    #[test]
322    fn test_detect_unsupported_types() {
323        for ct in [
324            &b""[..],
325            b"   ",
326            b"text/plain",
327            b"multipart/mixed; boundary=x",
328            b"application/json",
329        ] {
330            assert_eq!(
331                FormKind::detect(ct),
332                Err(FormError::UnsupportedMediaType),
333                "{ct:?}"
334            );
335        }
336    }
337
338    #[test]
339    fn test_detect_too_many_parameters_refused() {
340        let mut ct = alloc::vec::Vec::from(&b"multipart/form-data; boundary=x"[..]);
341        for i in 0..16 {
342            ct.extend_from_slice(alloc::format!("; p{i}=1").as_bytes());
343        }
344        assert_eq!(reason(&ct), Some(SyntaxError::InvalidContentType));
345        let _ = Limit::Body;
346    }
347}