fix(server): preserve UTF-8 boundaries in Content-Disposition filename parsing (#8880)

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 <czcbangkai@google.com>
This commit is contained in:
Jim Chen
2026-10-24 07:49:24 -07:00
committed by GitHub
parent cced213b66
commit 8ebec9e71c
+33 -5
View File
@@ -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<String> {
// 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);
}