use std::fmt;
use http::header::HeaderValue;
use crate::response::BackendError;
pub const DEFAULT_MULTIPART_MAX_BYTES: u64 = 32 * 1024 * 1024;
pub const DEFAULT_MULTIPART_MAX_PARTS: usize = 256;
const MAX_PARTS_CEILING: usize = 10_000;
const BOUNDARY_TRIES: usize = 4;
#[derive(Clone)]
struct Part {
name: String,
filename: Option<String>,
content_type: Option<String>,
data: Vec<u8>,
}
#[derive(Clone)]
pub struct Multipart {
parts: Vec<Part>,
max_bytes: u64,
max_parts: usize,
error: Option<BackendError>,
}
impl Default for Multipart {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for Multipart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let parts: Vec<(&str, bool, usize)> = self.parts.iter().map(|p| (p.name.as_str(), p.filename.is_some(), p.data.len())).collect();
f.debug_struct("Multipart").field("parts_name_isfile_bytes", &parts).field("max_bytes", &self.max_bytes).field("max_parts", &self.max_parts).finish()
}
}
impl Multipart {
pub fn new() -> Self {
Self { parts: Vec::new(), max_bytes: DEFAULT_MULTIPART_MAX_BYTES, max_parts: DEFAULT_MULTIPART_MAX_PARTS, error: None }
}
fn fail(&mut self, why: String) {
if self.error.is_none() {
self.error = Some(BackendError::InvalidRequest(why));
}
}
fn check_name(&mut self, name: &str) -> bool {
if name.is_empty() {
self.fail("a multipart field name is empty".into());
return false;
}
match unsafe_name(name) {
Some(why) => {
self.fail(format!("a multipart field name {why}"));
false
}
None => true,
}
}
pub fn text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
let name = name.into();
if self.check_name(&name) {
self.parts.push(Part { name, filename: None, content_type: None, data: value.into().into_bytes() });
}
self
}
pub fn file(mut self, name: impl Into<String>, filename: impl Into<String>, content_type: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
let name = name.into();
if !self.check_name(&name) {
return self;
}
let filename = filename.into();
if let Some(why) = unsafe_name(&filename) {
self.fail(format!("the file name of multipart field `{name}` {why}"));
return self;
}
let mut content_type = content_type.into();
if content_type.is_empty() {
content_type = "application/octet-stream".into();
}
if HeaderValue::try_from(content_type.as_str()).is_err() || content_type.contains(['\r', '\n']) {
self.fail(format!("the content type of multipart field `{name}` is not a valid header value"));
return self;
}
self.parts.push(Part { name, filename: Some(filename), content_type: Some(content_type), data: data.into() });
self
}
pub fn with_max_bytes(mut self, bytes: u64) -> Self {
self.max_bytes = bytes.max(1024);
self
}
pub fn with_max_parts(mut self, parts: usize) -> Self {
self.max_parts = parts.clamp(1, MAX_PARTS_CEILING);
self
}
pub fn len(&self) -> usize {
self.parts.len()
}
pub fn is_empty(&self) -> bool {
self.parts.is_empty()
}
pub fn encoded_len(&self) -> u64 {
encoded_len(&self.parts, BOUNDARY_LEN)
}
pub(crate) fn encode(&self) -> Result<(String, Vec<u8>), BackendError> {
self.check()?;
for _ in 0..BOUNDARY_TRIES {
let boundary = random_boundary()?;
if !self.parts.iter().any(|p| contains(&p.data, boundary.as_bytes()) || part_head(p).contains(&boundary)) {
return Ok(self.encode_with(&boundary));
}
}
Err(BackendError::InvalidRequest("could not find a multipart boundary that does not occur in the content".into()))
}
fn check(&self) -> Result<(), BackendError> {
if let Some(error) = &self.error {
return Err(error.clone());
}
if self.parts.len() > self.max_parts {
return Err(BackendError::InvalidRequest(format!("the form has {} parts, more than the limit of {}", self.parts.len(), self.max_parts)));
}
let size = self.encoded_len();
if size > self.max_bytes {
return Err(BackendError::RequestTooLarge { limit: self.max_bytes, size });
}
Ok(())
}
pub(crate) fn encode_with(&self, boundary: &str) -> (String, Vec<u8>) {
let size = usize::try_from(encoded_len(&self.parts, boundary.len())).unwrap_or(0);
let mut body = Vec::with_capacity(size);
for part in &self.parts {
body.extend_from_slice(b"--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(part_head(part).as_bytes());
body.extend_from_slice(&part.data);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(b"--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"--\r\n");
(format!("multipart/form-data; boundary={boundary}"), body)
}
}
const BOUNDARY_LEN: usize = 36;
fn random_boundary() -> Result<String, BackendError> {
let mut bytes = [0u8; 16];
crate::tls::random_bytes(&mut bytes).map_err(|()| BackendError::InvalidRequest("no randomness for the multipart boundary".into()))?;
let mut boundary = String::with_capacity(BOUNDARY_LEN);
boundary.push_str("bnb-");
for byte in bytes {
boundary.push_str(&format!("{byte:02x}"));
}
Ok(boundary)
}
fn unsafe_name(value: &str) -> Option<&'static str> {
if value.ends_with('\\') {
return Some("ends with a backslash (servers would misread the part)");
}
if value.chars().any(|c| c.is_control() && c != '\r' && c != '\n') {
return Some("contains a control character");
}
None
}
pub(crate) fn escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'"' => out.push_str("%22"),
'\r' => out.push_str("%0D"),
'\n' => out.push_str("%0A"),
c => out.push(c),
}
}
out
}
fn part_head(part: &Part) -> String {
let mut head = format!("Content-Disposition: form-data; name=\"{}\"", escape(&part.name));
if let Some(filename) = &part.filename {
head.push_str(&format!("; filename=\"{}\"", escape(filename)));
}
head.push_str("\r\n");
if let Some(content_type) = &part.content_type {
head.push_str(&format!("Content-Type: {content_type}\r\n"));
}
head.push_str("\r\n");
head
}
fn encoded_len(parts: &[Part], boundary_len: usize) -> u64 {
let mut total: u64 = 0;
let add = |total: &mut u64, n: usize| *total = total.saturating_add(u64::try_from(n).unwrap_or(u64::MAX));
for part in parts {
add(&mut total, 2 + boundary_len + 2);
add(&mut total, part_head(part).len());
add(&mut total, part.data.len());
add(&mut total, 2);
}
add(&mut total, 2 + boundary_len + 4);
total
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
let Some(&first) = needle.first() else { return true };
if haystack.len() < needle.len() {
return false;
}
let last_start = haystack.len() - needle.len();
let mut start = 0;
while start <= last_start {
match haystack.get(start..=last_start).and_then(|rest| rest.iter().position(|&b| b == first)) {
None => return false,
Some(offset) => {
let at = start + offset;
if haystack.get(at..at + needle.len()) == Some(needle) {
return true;
}
start = at + 1;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::{contains, escape, Multipart, BOUNDARY_LEN};
use crate::response::BackendError;
#[test]
fn golden_encoding_byte_for_byte() {
let form = Multipart::new()
.text("title", "Hello")
.text("tags[]", "a")
.text("tags[]", "b")
.file("avatar", "a.png", "image/png", vec![0u8, 1, 2])
.text("empty", "");
let (content_type, body) = form.encode_with("XyZ");
assert_eq!(content_type, "multipart/form-data; boundary=XyZ");
let expected: &[u8] = b"--XyZ\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nHello\r\n\
--XyZ\r\nContent-Disposition: form-data; name=\"tags[]\"\r\n\r\na\r\n\
--XyZ\r\nContent-Disposition: form-data; name=\"tags[]\"\r\n\r\nb\r\n\
--XyZ\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\nContent-Type: image/png\r\n\r\n\x00\x01\x02\r\n\
--XyZ\r\nContent-Disposition: form-data; name=\"empty\"\r\n\r\n\r\n\
--XyZ--\r\n";
assert_eq!(body, expected, "\n{}", String::from_utf8_lossy(&body));
assert_eq!(form.encoded_len() - u64::try_from(BOUNDARY_LEN - 3).unwrap_or(0) * 6, u64::try_from(body.len()).unwrap_or(0));
}
#[test]
fn names_and_filenames_are_escaped_like_browsers() {
assert_eq!(escape("a\"b\r\nc\\d é"), "a%22b%0D%0Ac\\d é");
let form = Multipart::new().file("fi\"le", "evil\"\r\nContent-Type: text/html.png", "", b"x".to_vec());
let (_, body) = form.encode_with("B");
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("name=\"fi%22le\"; filename=\"evil%22%0D%0AContent-Type: text/html.png\"\r\nContent-Type: application/octet-stream\r\n"),
"{text}"
);
assert_eq!(text.matches("\r\nContent-Type:").count(), 1, "a header was injected");
}
#[test]
fn the_random_boundary_is_fresh_valid_and_never_in_the_content() {
let form = Multipart::new().text("a", "--bnb-").file("f", "x", "", b"--bnb-0000\r\n".to_vec());
let (ct1, body1) = form.encode().unwrap_or_else(|e| panic!("{e}"));
let (ct2, _) = form.encode().unwrap_or_else(|e| panic!("{e}"));
assert_ne!(ct1, ct2, "the boundary must be random per request");
let boundary = ct1.strip_prefix("multipart/form-data; boundary=").unwrap_or_default();
assert_eq!(boundary.len(), BOUNDARY_LEN);
assert!(boundary.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'));
let delimiter = format!("--{boundary}");
assert_eq!(String::from_utf8_lossy(&body1).matches(&delimiter).count(), 3);
assert_eq!(u64::try_from(body1.len()).unwrap_or(0), form.encoded_len());
}
#[test]
fn boundary_search_finds_every_position() {
assert!(contains(b"abc--XyZ", b"--XyZ"));
assert!(contains(b"--XyZabc", b"--XyZ"));
assert!(!contains(b"--XyabcZ", b"--XyZ"));
assert!(!contains(b"", b"x"));
assert!(contains(b"---XyZ", b"--XyZ"));
}
#[test]
fn large_binary_and_empty_forms() {
let data: Vec<u8> = (0..3_000_000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect();
let form = Multipart::new().file("blob", "blob.bin", "", data.clone());
let (_, body) = form.encode().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(u64::try_from(body.len()).unwrap_or(0), form.encoded_len());
assert!(contains(&body, &data[1000..1100]));
let (_, empty) = Multipart::new().encode_with("B");
assert_eq!(empty, b"--B--\r\n");
}
#[test]
fn trailing_backslashes_and_control_characters_are_refused() {
for form in [
Multipart::new().text("n\\", "v"),
Multipart::new().file("f\\", "a.png", "image/png", vec![1u8]),
Multipart::new().file("up", "x\\", "image/png", vec![1u8]),
Multipart::new().file("up", "C:\\dir\\", "", vec![1u8]),
Multipart::new().text("a\u{0}b", "v"),
Multipart::new().file("up", "a\u{0}.png", "", vec![1u8]),
Multipart::new().file("up", "tab\there.png", "", vec![1u8]),
Multipart::new().text("del\u{7f}", "v"),
] {
assert!(matches!(form.encode(), Err(BackendError::InvalidRequest(_))), "{form:?}");
}
let ok = Multipart::new().text("a\\b", "line\u{0}\r\n").text("x\r\ny", "v").file("up", "C:\\Users\\me\\a.png", "", vec![0u8]).file("e", "", "", vec![]);
assert!(ok.encode().is_ok());
let (_, body) = Multipart::new().file("up", "C:\\Users\\me\\a.png", "", vec![0u8]).encode_with("B");
assert!(String::from_utf8_lossy(&body).contains("filename=\"C:\\Users\\me\\a.png\""));
let error = Multipart::new().file("up", "secret\\", "", vec![]).text("", "").encode().err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`up`") && error.contains("backslash") && !error.contains("secret"), "{error}");
}
#[test]
fn limits_and_bad_input_are_errors_not_panics() {
let big = Multipart::new().with_max_bytes(2048).file("f", "f", "", vec![0u8; 4096]);
assert!(matches!(big.encode(), Err(BackendError::RequestTooLarge { limit: 2048, size, .. }) if size > 4096));
let many = (0..5).fold(Multipart::new().with_max_parts(4), |form, i| form.text("x", i.to_string()));
assert!(matches!(many.encode(), Err(BackendError::InvalidRequest(why)) if why.contains("parts")));
assert!(matches!(Multipart::new().text("", "v").encode(), Err(BackendError::InvalidRequest(_))));
assert!(matches!(Multipart::new().file("f", "n", "image/png\r\nX: y", vec![]).encode(), Err(BackendError::InvalidRequest(_))));
assert!(matches!(Multipart::new().file("f", "n", "bad\u{0}type", vec![]).encode(), Err(BackendError::InvalidRequest(_))));
assert!(Multipart::new().file("f", "n", String::from("image/png"), vec![]).encode().is_ok());
let debug = format!("{:?}", Multipart::new().text("token", "fake-secret-3").file("f", "private.txt", "", b"fake-secret-4".to_vec()));
assert!(!debug.contains("fake-secret") && !debug.contains("private.txt"), "{debug}");
}
}