mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 06:18:43 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user