From 8ebec9e71c77e725930eb474d6ab65a804a1be3f Mon Sep 17 00:00:00 2001 From: Jim Chen <47835982+jim-czcbangkai@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:49:24 -0700 Subject: [PATCH] fix(server): preserve UTF-8 boundaries in Content-Disposition filename parsing (#8880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpCompressionConfig::extract_filename_from_content_disposition() calls header_value.to_lowercase() and slices &header_value[start..] using the byte index from lower.find("filename="), panicking on multi-byte Unicode characters whose UTF-8 byte length changes under case mapping and matching unanchored *filename= parameter suffixes. Root Cause: In rustfs/src/server/compress.rs, extract_filename_from_content_disposition() computes lower = header_value.to_lowercase() and uses the byte offset idx from lower.find("filename=") to slice &header_value[start..]. Because Unicode case mapping can expand or shrink UTF-8 byte lengths (e.g., U+0130 "İ" is 2 bytes in UTF-8 but lowercases to 3 bytes; U+212A "K" is 3 bytes in UTF-8 but lowercases to 1 byte), start can land inside a multi-byte UTF-8 codepoint or past the end of header_value, causing a runtime panic. In addition, lower.find("filename=") matches any parameter ending in filename= (such as x-filename=). Fix: Use header_value.to_ascii_lowercase() so byte offsets remain 1:1 with header_value, and require filename= to appear either at the start of the header or after a semicolon parameter delimiter. Signed-off-by: czcbangkai --- rustfs/src/server/compress.rs | 38 ++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/rustfs/src/server/compress.rs b/rustfs/src/server/compress.rs index f43c68ffb..2e54e3199 100644 --- a/rustfs/src/server/compress.rs +++ b/rustfs/src/server/compress.rs @@ -213,13 +213,21 @@ impl HttpCompressionConfig { /// Extract filename from Content-Disposition header /// Format: attachment; filename="example.txt" or attachment; filename=example.txt pub(crate) fn extract_filename_from_content_disposition(header_value: &str) -> Option { - // Look for filename= or filename*= parameter - let lower = header_value.to_lowercase(); + // Use ASCII lowercasing so byte offsets remain valid UTF-8 boundaries in header_value. + let lower = header_value.to_ascii_lowercase(); + let mut search_from = 0; + + // Try to find a semicolon-delimited filename="..." or filename=... parameter. + while let Some(rel_idx) = lower[search_from..].find("filename=") { + let idx = search_from + rel_idx; + let prefix = header_value[..idx].trim_end(); + if !prefix.is_empty() && !prefix.ends_with(';') { + search_from = idx + "filename=".len(); + continue; + } - // Try to find filename="..." or filename=... - if let Some(idx) = lower.find("filename=") { let start = idx + "filename=".len(); - let rest = &header_value[start..]; + let rest = header_value[start..].trim_start(); // Check if it's quoted if let Some(stripped) = rest.strip_prefix('"') { @@ -232,6 +240,8 @@ impl HttpCompressionConfig { let end = rest.find(';').unwrap_or(rest.len()); return Some(rest[..end].trim().to_string()); } + + search_from = start; } None @@ -646,6 +656,24 @@ mod tests { Some("test.json".to_string()) ); + // Multi-byte Unicode characters whose UTF-8 byte length changes under to_lowercase() + assert_eq!( + HttpCompressionConfig::extract_filename_from_content_disposition(r#"attachment; title="İ"; filename="report.txt""#), + Some("report.txt".to_string()) + ); + assert_eq!( + HttpCompressionConfig::extract_filename_from_content_disposition(r#"attachment; unit="K"; FILENAME="metrics.csv""#), + Some("metrics.csv".to_string()) + ); + + // Preceding parameter whose name ends with "filename" + assert_eq!( + HttpCompressionConfig::extract_filename_from_content_disposition( + r#"attachment; x-filename="decoy.txt"; filename="bundle.zip""# + ), + Some("bundle.zip".to_string()) + ); + // No filename assert_eq!(HttpCompressionConfig::extract_filename_from_content_disposition("inline"), None); }