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=` in any ASCII case. Readers that
71/// find `name=` and `filename=` by substring search (multer 3.1, the reader
72/// behind axum) take it for the parameter itself and read another field
73/// name, or a file where form-net reads a field (D-136).
74pub(crate) fn holds_name_assignment(value: &[u8]) -> bool {
75    value.contains(&b'=') && value.windows(5).any(|w| w.eq_ignore_ascii_case(b"name="))
76}
77
78/// The bytes of a parameter value as sent: a token, or the content of a
79/// quoted string with backslashes kept literal.
80pub(crate) const fn word_raw<'a>(w: &Word<'a>) -> &'a [u8] {
81    match w {
82        Word::Token(t) => t,
83        Word::Quoted(q) => q.raw(),
84    }
85}
86
87/// Which form encoding a request body uses.
88///
89/// # Examples
90///
91/// ```
92/// use form_net::{FormError, FormKind};
93///
94/// match FormKind::detect(b"multipart/form-data; boundary=----WebKitFormBoundaryx7Y")? {
95///     FormKind::Urlencoded => { /* parse_urlencoded */ }
96///     FormKind::Multipart(boundary) => assert_eq!(boundary.as_bytes(), b"----WebKitFormBoundaryx7Y"),
97///     _ => return Err(FormError::UnsupportedMediaType),
98/// }
99/// assert_eq!(FormKind::detect(b"Application/X-WWW-Form-Urlencoded; charset=UTF-8")?, FormKind::Urlencoded);
100/// assert_eq!(FormKind::detect(b"text/plain"), Err(FormError::UnsupportedMediaType));
101/// # Ok::<(), FormError>(())
102/// ```
103#[non_exhaustive]
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum FormKind {
106    /// `application/x-www-form-urlencoded`.
107    Urlencoded,
108    /// `multipart/form-data`, with its validated boundary.
109    Multipart(Boundary),
110}
111
112impl FormKind {
113    /// Reads a request's `Content-Type` value. A pure function of the header
114    /// bytes; it never looks at the body.
115    ///
116    /// `application/x-www-form-urlencoded` is accepted with any parameters
117    /// except a `charset` other than UTF-8 or US-ASCII. `multipart/form-data`
118    /// needs exactly one valid `boundary`. Everything else, including
119    /// `text/plain`, is `UnsupportedMediaType`. A repeated parameter, an
120    /// empty parameter (`;;`), and an extended parameter (`boundary*`,
121    /// `charset*0`) are refused (D-136).
122    ///
123    /// # Errors
124    ///
125    /// `UnsupportedMediaType`; `Syntax { InvalidContentType | MissingBoundary
126    /// | DuplicateBoundary | InvalidBoundary }`; `Encoding { UnsupportedCharset }`.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use form_net::{FormError, FormKind, SyntaxError};
132    ///
133    /// assert!(matches!(
134    ///     FormKind::detect(b"multipart/form-data; boundary=a; boundary=b"),
135    ///     Err(FormError::Syntax { reason: SyntaxError::DuplicateBoundary, .. })
136    /// ));
137    /// ```
138    pub fn detect(content_type: &[u8]) -> Result<FormKind, FormError> {
139        if parse_net::text::trim_ows(content_type).is_empty() {
140            return Err(FormError::UnsupportedMediaType);
141        }
142        let mt = parse_net::media::parse(content_type, &HEADER_LIMITS).map_err(|e| {
143            FormError::syntax(
144                SyntaxError::InvalidContentType,
145                Location::at(e.offset() as u64),
146            )
147        })?;
148        let urlencoded = mt.is_form_urlencoded();
149        if !urlencoded && !mt.is_multipart_form_data() {
150            return Err(FormError::UnsupportedMediaType);
151        }
152        let params = mt.params();
153        if has_empty_parameter(content_type) {
154            return Err(FormError::syntax(
155                SyntaxError::InvalidContentType,
156                Location::at(0),
157            ));
158        }
159        // `*` is in no browser's header: the fast byte search keeps the
160        // parameter walk off the common path.
161        if let Some(p) = content_type
162            .contains(&b'*')
163            .then(|| params.iter().find(|p| p.name.contains(&b'*')))
164            .flatten()
165        {
166            // `boundary*=UTF-8''Y` beside `boundary=X`: Go's
167            // `mime.ParseMediaType` frames the body with Y, form-net would
168            // with X (D-136). The same reasoning as D-081 for part headers.
169            let reason = if p.name.len() >= 8
170                && p.name
171                    .get(..8)
172                    .is_some_and(|n| n.eq_ignore_ascii_case(b"boundary"))
173            {
174                SyntaxError::InvalidBoundary
175            } else {
176                SyntaxError::InvalidContentType
177            };
178            return Err(FormError::syntax(reason, Location::at(0)));
179        }
180        if let Some(name) = repeated_name(&params) {
181            let reason = if name.eq_ignore_ascii_case(b"boundary") {
182                SyntaxError::DuplicateBoundary
183            } else {
184                SyntaxError::InvalidContentType
185            };
186            return Err(FormError::syntax(reason, Location::at(0)));
187        }
188        if urlencoded {
189            if let Some(cs) = params.get("charset") {
190                if !(cs.eq_ignore_ascii_case(b"utf-8") || cs.eq_ignore_ascii_case(b"us-ascii")) {
191                    return Err(FormError::encoding(
192                        EncodingError::UnsupportedCharset,
193                        Location::at(0),
194                    ));
195                }
196            }
197            return Ok(FormKind::Urlencoded);
198        }
199        let Some(word) = params.get("boundary") else {
200            return Err(FormError::syntax(
201                SyntaxError::MissingBoundary,
202                Location::at(0),
203            ));
204        };
205        if let Word::Quoted(q) = word {
206            if q.has_escapes() {
207                return Err(FormError::syntax(
208                    SyntaxError::InvalidBoundary,
209                    Location::at(0),
210                ));
211            }
212        }
213        Boundary::new(word_raw(&word)).map(FormKind::Multipart)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::error::Limit;
221
222    fn reason(ct: &[u8]) -> Option<SyntaxError> {
223        match FormKind::detect(ct) {
224            Err(FormError::Syntax { reason, .. }) => Some(reason),
225            _ => None,
226        }
227    }
228
229    #[test]
230    fn test_detect_urlencoded_cases() {
231        for ct in [
232            &b"application/x-www-form-urlencoded"[..],
233            b"APPLICATION/X-WWW-FORM-URLENCODED",
234            b"application/x-www-form-urlencoded; charset=UTF-8",
235            b"application/x-www-form-urlencoded; charset=\"utf-8\"",
236            b"application/x-www-form-urlencoded; charset=us-ascii; other=1",
237            b"  application/x-www-form-urlencoded  ",
238        ] {
239            assert_eq!(FormKind::detect(ct), Ok(FormKind::Urlencoded), "{ct:?}");
240        }
241        assert!(matches!(
242            FormKind::detect(b"application/x-www-form-urlencoded; charset=iso-8859-1"),
243            Err(FormError::Encoding {
244                reason: EncodingError::UnsupportedCharset,
245                ..
246            })
247        ));
248        assert_eq!(
249            reason(b"application/x-www-form-urlencoded; charset=utf-8; Charset=utf-8"),
250            Some(SyntaxError::InvalidContentType)
251        );
252    }
253
254    #[test]
255    fn test_detect_multipart_boundary_rules() {
256        assert!(matches!(
257            FormKind::detect(b"multipart/form-data; boundary=x"),
258            Ok(FormKind::Multipart(_))
259        ));
260        assert!(matches!(
261            FormKind::detect(b"Multipart/Form-Data; Boundary=\"a b\""),
262            Ok(FormKind::Multipart(b)) if b.as_bytes() == b"a b"
263        ));
264        assert_eq!(
265            reason(b"multipart/form-data"),
266            Some(SyntaxError::MissingBoundary)
267        );
268        assert_eq!(
269            reason(b"multipart/form-data; boundary=a; boundary=a"),
270            Some(SyntaxError::DuplicateBoundary)
271        );
272        assert_eq!(
273            reason(b"multipart/form-data; boundary=\"\""),
274            Some(SyntaxError::InvalidBoundary)
275        );
276        assert_eq!(
277            reason(b"multipart/form-data; boundary=\"ab \""),
278            Some(SyntaxError::InvalidBoundary)
279        );
280        assert_eq!(
281            reason(b"multipart/form-data; boundary=\"a\\b\""),
282            Some(SyntaxError::InvalidBoundary)
283        );
284        assert_eq!(
285            reason(b"multipart/form-data; boundary="),
286            Some(SyntaxError::InvalidContentType)
287        );
288        assert_eq!(
289            reason(b"multipart/form-data; boundary=a; x=1; X=2"),
290            Some(SyntaxError::InvalidContentType)
291        );
292        assert_eq!(
293            reason(b"multipart/form-data; boundary=x junk"),
294            Some(SyntaxError::InvalidContentType)
295        );
296    }
297
298    #[test]
299    fn test_detect_unsupported_types() {
300        for ct in [
301            &b""[..],
302            b"   ",
303            b"text/plain",
304            b"multipart/mixed; boundary=x",
305            b"application/json",
306        ] {
307            assert_eq!(
308                FormKind::detect(ct),
309                Err(FormError::UnsupportedMediaType),
310                "{ct:?}"
311            );
312        }
313    }
314
315    #[test]
316    fn test_detect_too_many_parameters_refused() {
317        let mut ct = alloc::vec::Vec::from(&b"multipart/form-data; boundary=x"[..]);
318        for i in 0..16 {
319            ct.extend_from_slice(alloc::format!("; p{i}=1").as_bytes());
320        }
321        assert_eq!(reason(&ct), Some(SyntaxError::InvalidContentType));
322        let _ = Limit::Body;
323    }
324}