Files
Aether/apps/aether-gateway/src/handlers/shared/multipart.rs
T

351 lines
11 KiB
Rust
Raw Normal View History

/// RFC 2046 limits a multipart boundary to at most 70 characters. Using
/// HTTP token syntax here also keeps the value safe to embed in the byte
/// delimiter used by the lightweight parsers.
pub(crate) const MAX_MULTIPART_BOUNDARY_BYTES: usize = 70;
/// Keep multipart metadata bounded independently of the file payload size.
pub(crate) const MAX_MULTIPART_PARTS: usize = 128;
pub(crate) const MAX_MULTIPART_PART_HEADER_BYTES: usize = 64 * 1024;
/// Find a multipart delimiter at the beginning of a buffer or after CRLF.
/// The returned index points at the delimiter itself (not the preceding CRLF).
pub(crate) fn find_multipart_boundary(haystack: &[u8], delimiter: &[u8]) -> Option<usize> {
find_multipart_boundary_inner(haystack, delimiter, true)
}
/// Find a multipart delimiter that is preceded by CRLF. This variant is used
/// while scanning a part payload, where an apparent delimiter at byte zero is
/// payload data rather than a valid framing boundary.
pub(crate) fn find_multipart_boundary_after_crlf(
haystack: &[u8],
delimiter: &[u8],
) -> Option<usize> {
find_multipart_boundary_inner(haystack, delimiter, false)
}
fn find_multipart_boundary_inner(
haystack: &[u8],
delimiter: &[u8],
allow_start: bool,
) -> Option<usize> {
if delimiter.is_empty() {
return None;
}
if allow_start && multipart_boundary_is_valid_at(haystack, 0, delimiter) {
return Some(0);
}
let window_len = delimiter.len().checked_add(2)?;
haystack
.windows(window_len)
.enumerate()
.find_map(|(index, window)| {
if &window[..2] != b"\r\n" || &window[2..] != delimiter {
return None;
}
let delimiter_index = index + 2;
multipart_boundary_is_valid_at(haystack, delimiter_index, delimiter)
.then_some(delimiter_index)
})
}
fn multipart_boundary_is_valid_at(haystack: &[u8], index: usize, delimiter: &[u8]) -> bool {
let suffix_start = index.checked_add(delimiter.len());
let Some(suffix_start) = suffix_start else {
return false;
};
if !haystack
.get(index..)
.is_some_and(|remaining| remaining.starts_with(delimiter))
{
return false;
}
let Some(suffix) = haystack.get(suffix_start..) else {
return false;
};
if suffix.starts_with(b"\r\n") {
return true;
}
suffix
.strip_prefix(b"--")
.is_some_and(|remaining| remaining.is_empty() || remaining.starts_with(b"\r\n"))
}
/// Extract and validate a multipart boundary parameter.
///
/// The parameter may use the usual optional surrounding quotes, but the
/// boundary value itself must be an ASCII HTTP token. Rejecting malformed
/// values at the content-type boundary prevents parser ambiguity and bounds
/// the work performed by downstream delimiter scans.
pub(crate) fn parse_multipart_boundary(content_type: &str) -> Option<String> {
let segments = split_multipart_header_parameters(content_type)?;
let media_type = segments.first()?.trim();
if !media_type.eq_ignore_ascii_case("multipart/form-data") {
return None;
}
let mut boundary = None;
let mut seen_keys = Vec::new();
for segment in segments.into_iter().skip(1) {
let segment = segment.trim();
if segment.is_empty() {
return None;
}
let (raw_key, raw_value) = segment.split_once('=')?;
let key = raw_key.trim();
if key.is_empty() || !key.as_bytes().iter().copied().all(is_http_token_byte) {
return None;
}
if seen_keys
.iter()
.any(|seen: &String| seen.eq_ignore_ascii_case(key))
{
return None;
}
seen_keys.push(key.to_ascii_lowercase());
let (value, had_escape) = parse_multipart_parameter_value(raw_value.trim())?;
if !key.eq_ignore_ascii_case("boundary") {
continue;
}
// Keep boundary parsing deliberately narrower than generic quoted
// parameter parsing: escaped boundary values are ambiguous across
// HTTP stacks and are rejected here.
if had_escape {
return None;
}
if !is_valid_multipart_boundary(&value) {
return None;
}
boundary = Some(value);
}
boundary
}
/// Split a semicolon-delimited HTTP header while honoring quoted strings.
/// Returning `None` for an unterminated quote or escape prevents a malformed
/// parameter from being reinterpreted by a downstream parser.
fn split_multipart_header_parameters(value: &str) -> Option<Vec<&str>> {
let mut segments = Vec::new();
let mut start = 0usize;
let mut in_quotes = false;
let mut escaped = false;
for (index, character) in value.char_indices() {
if character.is_ascii_control() {
return None;
}
if in_quotes {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
in_quotes = false;
}
} else if character == '"' {
in_quotes = true;
} else if character == ';' {
segments.push(&value[start..index]);
start = index + character.len_utf8();
}
}
if in_quotes || escaped {
return None;
}
segments.push(&value[start..]);
Some(segments)
}
fn parse_multipart_parameter_value(value: &str) -> Option<(String, bool)> {
if value.is_empty() {
return None;
}
if value.starts_with('"') {
if value.len() < 2 || !value.ends_with('"') {
return None;
}
let inner = &value[1..value.len() - 1];
let mut parsed = String::with_capacity(inner.len());
let mut escaped = false;
let mut had_escape = false;
for character in inner.chars() {
if escaped {
if character.is_ascii_control() {
return None;
}
parsed.push(character);
escaped = false;
had_escape = true;
} else if character == '\\' {
escaped = true;
} else {
if character == '"' || character.is_ascii_control() {
return None;
}
parsed.push(character);
}
}
if escaped {
return None;
}
return Some((parsed, had_escape));
}
value
.as_bytes()
.iter()
.copied()
.all(is_http_token_byte)
.then(|| (value.to_string(), false))
}
fn is_valid_multipart_boundary(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_MULTIPART_BOUNDARY_BYTES
&& value.as_bytes().iter().copied().all(is_http_token_byte)
}
fn is_http_token_byte(byte: u8) -> bool {
matches!(
byte,
b'0'..=b'9'
| b'A'..=b'Z'
| b'a'..=b'z'
| b'!'
| b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
#[cfg(test)]
mod tests {
use super::{
find_multipart_boundary, find_multipart_boundary_after_crlf, parse_multipart_boundary,
MAX_MULTIPART_BOUNDARY_BYTES, MAX_MULTIPART_PARTS, MAX_MULTIPART_PART_HEADER_BYTES,
};
#[test]
fn accepts_token_boundary_and_optional_quotes() {
assert_eq!(
parse_multipart_boundary("Multipart/Form-Data; boundary=----WebKitFormBoundaryabc123")
.as_deref(),
Some("----WebKitFormBoundaryabc123")
);
assert_eq!(
parse_multipart_boundary("multipart/form-data; boundary=\"quoted-boundary\"")
.as_deref(),
Some("quoted-boundary")
);
}
#[test]
fn rejects_non_token_control_quote_and_oversized_boundaries() {
for content_type in [
"multipart/form-data; boundary=",
"multipart/form-data; boundary=bad boundary",
"multipart/form-data; boundary=\"bad;boundary\"",
"multipart/form-data; boundary=bad\r\nvalue",
"multipart/form-data; boundary=bad\"quote",
"multipart/form-data; boundary=\"unterminated",
"multipart/form-data; foo",
"multipart/form-data; foo=\"unterminated; boundary=valid",
"multipart/form-data; boundary=valid trailing",
] {
assert!(
parse_multipart_boundary(content_type).is_none(),
"{content_type:?}"
);
}
let oversized = "a".repeat(MAX_MULTIPART_BOUNDARY_BYTES + 1);
assert!(
parse_multipart_boundary(&format!("multipart/form-data; boundary={oversized}"))
.is_none()
);
}
#[test]
fn rejects_duplicate_boundary_parameters() {
for content_type in [
"multipart/form-data; boundary=first; boundary=second",
"multipart/form-data; boundary=first; BOUNDARY=second",
] {
assert!(
parse_multipart_boundary(content_type).is_none(),
"duplicate boundary parameters must be rejected: {content_type}"
);
}
}
#[test]
fn accepts_quoted_unknown_parameters_with_semicolons() {
assert_eq!(
parse_multipart_boundary(
"multipart/form-data; note=\"semi;colon\"; boundary=quoted-token"
)
.as_deref(),
Some("quoted-token")
);
}
#[test]
fn rejects_escaped_boundary_and_duplicate_unknown_parameters() {
for content_type in [
"multipart/form-data; boundary=\"escaped\\\"token\"",
"multipart/form-data; note=one; NOTE=two; boundary=token",
"multipart/form-data; note=\"unterminated; boundary=token",
"multipart/form-data; note=\"closed\"trailing; boundary=token",
] {
assert!(
parse_multipart_boundary(content_type).is_none(),
"malformed content type must be rejected: {content_type:?}"
);
}
}
#[test]
fn rejects_non_multipart_media_types() {
assert!(parse_multipart_boundary("application/json; boundary=abc").is_none());
assert!(parse_multipart_boundary("x-multipart/form-data; boundary=abc").is_none());
}
#[test]
fn multipart_metadata_limits_remain_bounded() {
assert_eq!(MAX_MULTIPART_PARTS, 128);
assert_eq!(MAX_MULTIPART_PART_HEADER_BYTES, 64 * 1024);
}
#[test]
fn boundary_scanner_ignores_embedded_markers_and_invalid_suffixes() {
let delimiter = b"--boundary";
let payload = b"prefix\r\n--boundaryX\r\nmore\r\n--boundary\r\n";
assert_eq!(
find_multipart_boundary_after_crlf(payload, delimiter),
Some(payload.len() - delimiter.len() - 2)
);
assert_eq!(
find_multipart_boundary(b"payload--boundary\r\n", delimiter),
None
);
assert_eq!(find_multipart_boundary(b"--boundaryX\r\n", delimiter), None);
assert_eq!(
find_multipart_boundary_after_crlf(b"payload\r\n--boundaryX\r\n", delimiter),
None
);
}
}