Files
craft-libs/crates/mime/tests/hostile.rs
Dylan M. Taylor d4e3017c34 Add craft-mime: Internet message and MIME reader and writer (#814)
MailCraft and WordCraft each parse MIME: MailCraft for mail (crates/mime,
crates/charset), WordCraft for MHT web archives (formats/src/mht.rs). This
adds one shared crate for the format, taken from MailCraft's code where it
is format code, with WordCraft's multipart/related lookups and its fix for
comments in Content-Type.

Reader: parse() gives a tree of parts (header fields, content type and
parameters, decoded body, charset-decoded text). It never fails or panics,
is linear in the input, and is bounded by Limits (input size, header count
and sizes, depth, part count, decoded size); Message::truncated reports a
limit that left something out.

Writer: Entity::text / file / multipart and write_message, with folded
headers, RFC 2047 and RFC 2231 encoding, quoted-printable and base64 with
line limits, and boundaries checked against the content. Every header
value loses CR, LF and NUL, and a name that is no field name is not
written.

Also: address lists, mbox splitting and writing on raw message bytes, and
Date parsing and formatting on a plain struct (chrono is a private
dependency; no date type is in the public API).

No app is switched here. The README has the migration tables and says what
stays in the apps.
2026-11-11 12:48:08 -05:00

309 lines
14 KiB
Rust

//! Hostile input and hostile values: every prefix and many mutations of real messages read
//! without a panic and within their bounds, and nothing a caller hands the writer can add a
//! header field or a part.
use craft_mime::{Boundaries, Entity, File, Limits, Mailbox, Message, Part};
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
/// A message with every construct the reader knows, written by the writer.
fn sample() -> Vec<u8> {
let mut boundaries = Boundaries::default();
let picture = Entity::file(&File { media_type: "image/png", name: "lögo (1).png", inline: true, content_id: "logo@example.org", data: PNG });
let mut page = Entity::text("html", "<p>Grüße <img src=\"cid:logo@example.org\"></p>\n");
page.fields.push(("Content-Location".into(), "https://example.test/reports/page.html".into()));
let rich = Entity::multipart("related", &[("type", "text/html"), ("start", "<page>")], &[page, picture], &mut boundaries);
let forms = Entity::multipart("alternative", &[], &[Entity::text("plain", "Grüße\n\nFrom here on.\n"), rich], &mut boundaries);
let inner = craft_mime::write_message(&[("Subject", "Inner")], &Entity::text("plain", "inner"));
let attached = Entity::file(&File { media_type: "message/rfc822", name: "inner.eml", data: &inner, ..File::default() });
let root = Entity::multipart("mixed", &[], &[forms, attached], &mut boundaries);
let to = craft_mime::format_address_list(&[
Mailbox { name: "Hopper, Grace".into(), email: "grace@example.org".into() },
Mailbox { name: "Renée".into(), email: "renee@example.org".into() },
]);
let subject = craft_mime::encode_unstructured("Subject", &"Ünïcödé subject ".repeat(8));
craft_mime::write_message(
&[("Date", "Tue, 01 Jul 2003 10:52:37 +0200"), ("To", &to), ("Subject", &subject), ("Message-ID", "<abc@example.org>")],
&root,
)
}
/// A hand-written MHT archive with LF line ends, a comment in its content type and RFC 2231
/// parameters.
fn archive() -> Vec<u8> {
b"From nobody Tue Jul 1 08:52:37 2003\n\
MIME-Version: 1.0\n\
Content-Type: multipart/related (a \"comment\"; boundary=wrong);\n\
\ttype=\"text/html\"; start=\"<page>\"; boundary=\"----=_B\"\n\
\n\
preamble\n\
------=_B\n\
Content-ID: <page>\n\
Content-Location: file:///C:/page.htm\n\
Content-Transfer-Encoding: quoted-printable\n\
Content-Type: text/html; charset=\"windows-1250\"\n\
\n\
<p class=3D\"x\">=E8lanak, jedan =\n\
red.</p><img src=3D\"page_files/dot.png\">\n\
------=_B\n\
Content-Location: file:///C:/page_files/dot.png\n\
Content-Transfer-Encoding: base64\n\
Content-Type: image/png\n\
Content-Disposition: inline; filename*0*=utf-8''d%C3%B6;\n\
\x20filename*1=\"t.png\"\n\
\n\
iVBORw0KGgoAAAANSUhEUg==\n\
------=_B--\n\
epilogue\n"
.to_vec()
}
/// Everything a caller can ask of a message, and what must hold whatever the bytes were.
fn exercise(bytes: &[u8], limits: &Limits) -> Message {
let message = craft_mime::parse(bytes, limits);
let mut stack = vec![(&message.root, 0usize)];
let (mut parts, mut decoded) = (0usize, 0usize);
while let Some((part, depth)) = stack.pop() {
parts += 1;
decoded += part.data().len();
assert!(depth <= limits.max_depth.min(Limits::HARD_MAX_DEPTH));
assert!(part.headers.0.len() <= limits.max_header_fields);
assert!(
part.headers.0.iter().all(|f| craft_mime::is_field_name(f.name.as_bytes()) && f.value.len() <= limits.max_header_value.saturating_mul(3))
);
let _ = (part.text(), part.file_name(), part.content_id(), part.content_location(), part.disposition(), part.transfer_encoding());
for field in &part.headers.0 {
let _ = (craft_mime::decode_words(&field.value), craft_mime::parse_address_list(&field.value), craft_mime::parse_date(&field.value));
}
stack.extend(part.parts().iter().map(|p| (p, depth + 1)));
}
assert!(parts <= limits.max_parts.max(1));
assert!(decoded <= limits.max_decoded.min(bytes.len()));
let resources = craft_mime::Resources::of(&message.root);
let _ = (
craft_mime::start_part(&message.root),
resources.find("page_files/dot.png", "file:///C:/page.htm"),
resources.find("cid:logo@example.org", ""),
);
assert_eq!(craft_mime::parse(bytes, limits), message, "reading is deterministic");
message
}
const SMALL: Limits = Limits {
max_input: 700,
max_header_fields: 3,
max_header_value: 40,
max_header_block: 120,
max_depth: 1,
max_parts: 3,
max_decoded: 50,
..Limits::DEFAULT
};
#[test]
fn the_samples_read_whole() {
let m = exercise(&sample(), &Limits::default());
assert!(!m.truncated);
let types: Vec<&str> = m.root.leaves().iter().map(|p| p.media_type()).collect();
assert_eq!(types, ["text/plain", "text/html", "image/png", "message/rfc822"]);
assert_eq!(m.root.leaves()[2].file_name().as_deref(), Some("lögo (1).png"));
assert_eq!(m.root.leaves()[2].data(), PNG);
let m = exercise(&archive(), &Limits::default());
assert!(!m.truncated);
assert_eq!(
craft_mime::start_part(&m.root).map(Part::text).as_deref(),
Some("<p class=\"x\">članak, jedan red.</p><img src=\"page_files/dot.png\">")
);
let picture = craft_mime::Resources::of(&m.root).find("page_files/dot.png", "file:///C:/page.htm");
assert_eq!(picture.and_then(Part::file_name).as_deref(), Some("döt.png"));
assert!(picture.is_some_and(|p| p.data().starts_with(b"\x89PNG")));
}
#[test]
fn every_truncation_reads() {
for bytes in [sample(), archive()] {
for end in 0..=bytes.len() {
let cut = bytes.get(..end).unwrap_or_default();
exercise(cut, &Limits::default());
exercise(cut, &SMALL);
// An mbox reader takes any prefix too.
assert!(craft_mime::mbox_messages(cut).map(|m| m.len()).sum::<usize>() <= cut.len());
}
}
}
#[test]
fn mutations_read() {
// A small xorshift generator: the same mutations every run.
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
const INTERESTING: &[u8] = b"\0\r\n\t \"\\()<>;:=?-_%*'/@,.\x7f\x80\xff";
for original in [sample(), archive()] {
for round in 0..4000u32 {
let mut bytes = original.clone();
for _ in 0..=(round % 4) {
let at = (next() as usize) % bytes.len().max(1);
let pick = INTERESTING.get((next() as usize) % INTERESTING.len()).copied().unwrap_or(0);
match next() % 5 {
0 => {
if let Some(b) = bytes.get_mut(at) {
*b = pick;
}
}
1 => bytes.insert(at.min(bytes.len()), pick),
2 if !bytes.is_empty() => {
bytes.remove(at);
}
// Repeat a slice of the message somewhere else (boundaries, header lines).
3 => {
let len = (next() as usize) % 80;
let slice = original.get(at..at.saturating_add(len).min(original.len())).unwrap_or_default().to_vec();
let to = (next() as usize) % bytes.len().max(1);
bytes.splice(to..to, slice);
}
_ => {
if let Some(b) = bytes.get_mut(at) {
*b ^= 1 << (next() % 8);
}
}
}
}
exercise(&bytes, &Limits::default());
exercise(&bytes, &SMALL);
}
}
}
#[test]
fn large_hostile_shapes_stay_linear() {
// Each of these is a megabyte or so; quadratic work on any would not finish.
let n = 200_000;
let shapes: Vec<Vec<u8>> = vec![
b"X: ".iter().copied().chain(b"=?".repeat(n)).collect(),
format!("Content-Type: multipart/mixed; boundary=b\n\n{}", "--b\n".repeat(n)).into_bytes(),
format!("Content-Type: multipart/mixed; boundary=b\n\n{}", "--b--x\n--bb\n".repeat(n / 2)).into_bytes(),
format!("Content-Type: text/plain{}\n\nx", "; a*0*=''%41".repeat(n / 8)).into_bytes(),
format!("Content-Type: text/plain; a=\"b\"{}\n\nx", "(".repeat(n)).into_bytes(),
format!("To: {}\n\nx", "(<\"\\".repeat(n / 2)).into_bytes(),
format!("A: 1\n{}\nbody", " fold\n".repeat(n)).into_bytes(),
"A: 1\n".repeat(n).into_bytes(),
format!("Content-Transfer-Encoding: quoted-printable\n\n{}", "= ".repeat(n)).into_bytes(),
format!("Content-Transfer-Encoding: base64\n\n{}", "=A".repeat(n)).into_bytes(),
"From \n\n".repeat(n).into_bytes(),
];
for bytes in &shapes {
exercise(bytes, &Limits::default());
let _ = craft_mime::mbox_messages(bytes).count();
}
}
/// The header block of a written message, as lines; no line of it may start a field the caller
/// didn't write.
fn header_lines(bytes: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(bytes).into_owned();
assert!(!text.contains('\0'), "{text:?}");
// Every CR is followed by LF and every LF follows a CR: no bare line break got through.
assert_eq!(text.matches('\r').count(), text.matches("\r\n").count());
assert_eq!(text.matches('\n').count(), text.matches("\r\n").count());
text.split("\r\n\r\n").next().unwrap_or_default().split("\r\n").map(str::to_string).collect()
}
const EVIL: &[&str] = &[
"x\r\nBcc: eve@example.org",
"x\nX-Evil: 1",
"x\rX-Evil: 2",
"x\r\n\r\n<script>body</script>",
"x\0\r\n X-Evil: 3",
"x\"\r\nX-Evil: 4",
"x>\r\nX-Evil: 5\r\n<y",
"é\r\nX-Evil: 6",
"\r\nX-Evil: 7",
"x\u{2028}X-Evil: 8\u{85}X-Evil: 9",
];
fn assert_no_injection(bytes: &[u8], allowed: &[&str]) {
for line in header_lines(bytes) {
let name = line.split(':').next().unwrap_or_default();
assert!(line.starts_with([' ', '\t']) || allowed.iter().any(|a| a.eq_ignore_ascii_case(name)), "an injected line: {line:?}");
}
// And a reader sees only those fields.
let m = craft_mime::parse(bytes, &Limits::default());
for field in &m.root.headers.0 {
assert!(allowed.iter().any(|a| a.eq_ignore_ascii_case(&field.name)), "an injected field: {field:?}");
}
}
#[test]
fn header_values_cannot_inject_fields() {
const ALLOWED: &[&str] = &["Subject", "X-Note", "To", "Message-ID", "MIME-Version", "Content-Type", "Content-Transfer-Encoding"];
for evil in EVIL {
// Raw, and through each encoder a caller would use.
let to = craft_mime::format_address_list(&[Mailbox { name: evil.to_string(), email: format!("a@example.org{evil}") }]);
let id = format!("<{}>", craft_mime::clean_id(evil));
for subject in [evil.to_string(), craft_mime::encode_unstructured("Subject", evil), craft_mime::encode_words(evil)] {
let note = craft_mime::seven_bit_value("X-Note", evil);
let headers = [("Subject", subject.as_str()), ("X-Note", note.as_str()), ("To", to.as_str()), ("Message-ID", id.as_str())];
let out = craft_mime::write_message(&headers, &Entity::text("plain", "body"));
assert_no_injection(&out, ALLOWED);
let m = craft_mime::parse(&out, &Limits::default());
assert_eq!(m.root.data(), b"body\r\n", "the body starts where the writer put it");
assert_eq!(craft_mime::parse_address_list(m.root.headers.get("to").unwrap_or_default()).len(), 1, "{to}");
}
}
}
#[test]
fn header_names_cannot_inject_fields() {
for name in ["X-A\r\nBcc", "X-A: 1\r\nBcc", "X A", "", "X-A\0", "Bcc:", "X-É"] {
let out = craft_mime::write_message(&[(name, "eve@example.org")], &Entity::text("plain", "body"));
assert_no_injection(&out, &["MIME-Version", "Content-Type", "Content-Transfer-Encoding"]);
let mut entity = Entity::text("plain", "body");
entity.fields.push((name.to_string(), "eve@example.org".to_string()));
assert_no_injection(&craft_mime::write_message(&[], &entity), &["MIME-Version", "Content-Type", "Content-Transfer-Encoding"]);
}
}
#[test]
fn part_values_cannot_inject_fields_or_parts() {
const ALLOWED: &[&str] = &["MIME-Version", "Content-Type", "Content-Transfer-Encoding", "Content-Disposition", "Content-ID", "Content-Location"];
for evil in EVIL {
let mut file = Entity::file(&File { media_type: evil, name: evil, inline: false, content_id: evil, data: b"data" });
file.fields.push(("Content-Location".into(), evil.to_string()));
assert_no_injection(&craft_mime::write_message(&[], &file), ALLOWED);
assert_no_injection(&craft_mime::write_message(&[], &Entity::text(evil, "x")), ALLOWED);
// In a multipart: the subtype, a parameter's name and value, the boundary prefix, and
// a body that holds what looks like a delimiter.
let mut boundaries = Boundaries::new(evil);
let text = Entity::text("plain", &format!("--{evil}\n--=_craft_\n{evil}"));
let root = Entity::multipart(evil, &[("type", evil), (evil, "x"), ("boundary", "mine")], &[text, file], &mut boundaries);
let out = craft_mime::write_message(&[], &root);
assert_no_injection(&out, ALLOWED);
let m = craft_mime::parse(&out, &Limits::default());
assert_eq!(m.root.parts().len(), 2, "{}", String::from_utf8_lossy(&out));
assert_eq!(m.root.parts()[1].data(), b"data");
for part in m.root.parts() {
assert!(part.headers.0.iter().all(|f| ALLOWED.iter().any(|a| a.eq_ignore_ascii_case(&f.name))), "{:?}", part.headers);
}
}
}
#[test]
fn mbox_values_cannot_inject_messages() {
let date = craft_mime::parse_date("Tue, 1 Jul 2003 08:52:37 +0000").unwrap_or_default();
for evil in EVIL {
let mut out = Vec::new();
let sender = format!("a@example.org{evil}\nFrom eve@example.org Tue Jul 1 08:52:37 2003");
let message = format!("Subject: x\r\n\r\nFrom the top\r\n\r\nFrom eve@example.org Tue Jul 1 08:52:37 2003\r\n{evil}\r\n");
craft_mime::write_mbox_message(&mut out, &sender, &date, message.as_bytes());
let back: Vec<Vec<u8>> = craft_mime::mbox_messages(&out).collect();
assert_eq!(back, [message.into_bytes()]);
}
}