fix(http): decode header values as ISO-8859-1 (#3586)

* fix(http): decode response header values as ISO-8859-1

Response header values were decoded with String::from_utf8 before being
sent over IPC. Raw UTF-8 bytes produced JS strings with code points
above 255, which new Headers() rejects as invalid ByteStrings
("Type error" on WebKit), and bytes that were not valid UTF-8 failed
the fetch outright. Decode as ISO-8859-1 instead so every byte maps to a
char 0-255, matching what browsers expose for the same response.

Fixes #2898

* fix(http): add tests for ISO-8859-1 response header decoding

Cover the #2898 Content-Disposition case and non-UTF-8 header bytes on
the same conversion fetch_send uses, so a revert to UTF-8 fails the suite.

* request headers, e2e test

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
Christopher PruijsenandLucas Nogueira authored and GitHub committed 2026-10-13 14:43:50 -03:00
1 parent 62b3565fb5
commit 0e17675653
6 files changed
+141 -7

No files matched your search

+70 -5
View File
@@ -267,7 +267,7 @@ pub async fn fetch<R: Runtime>(
continue;
}
headers.append(name, HeaderValue::from_str(&v)?);
headers.append(name, byte_string_to_header_value(&v)?);
}
match scheme {
@@ -419,6 +419,26 @@ pub fn fetch_cancel<R: Runtime>(webview: Webview<R>, rid: ResourceId) -> crate::
Ok(())
}
// Header values are byte strings. Decode as ISO-8859-1 so the raw bytes survive
// as a valid ByteString for `new Headers()` on the JS side.
fn header_value_to_byte_string(value: &HeaderValue) -> String {
value.as_bytes().iter().map(|&b| b as char).collect()
}
// The inverse of `header_value_to_byte_string`: a ByteString from `Headers` on the
// JS side carries one byte per char, so send those bytes as-is like a browser does.
// Values with chars above U+00FF can't come from `Headers`; keep encoding those as UTF-8.
fn byte_string_to_header_value(value: &str) -> crate::Result<HeaderValue> {
let bytes = value
.chars()
.map(|c| u8::try_from(c).ok())
.collect::<Option<Vec<u8>>>();
Ok(match bytes {
Some(bytes) => HeaderValue::from_bytes(&bytes)?,
None => HeaderValue::from_str(value)?,
})
}
#[command]
pub async fn fetch_send<R: Runtime>(
webview: Webview<R>,
@@ -458,10 +478,7 @@ pub async fn fetch_send<R: Runtime>(
let url = res.url().to_string();
let mut headers = Vec::new();
for (key, val) in res.headers().iter() {
headers.push((
key.as_str().into(),
String::from_utf8(val.as_bytes().to_vec())?,
));
headers.push((key.as_str().into(), header_value_to_byte_string(val)));
}
let mut resources_table = webview.resources_table();
@@ -728,4 +745,52 @@ mod tests {
assert_eq!(response.status(), StatusCode::FOUND);
}
fn assert_byte_string(value: &HeaderValue) {
let decoded = header_value_to_byte_string(value);
assert!(
decoded.chars().all(|c| u32::from(c) <= 255),
"header value must be a JS ByteString (every code point <= 255)"
);
assert_eq!(
decoded.chars().map(|c| c as u8).collect::<Vec<_>>(),
value.as_bytes(),
"ISO-8859-1 decode must preserve the wire bytes"
);
let json = serde_json::to_string(&decoded).unwrap();
let roundtrip: String = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, decoded);
}
#[test]
fn utf8_content_disposition_filename_is_byte_string() {
// Issue #2898: `filename="中文-EN.pdf"` sent as raw UTF-8 on the wire.
let value =
HeaderValue::from_bytes(b"attachment; filename=\"\xe4\xb8\xad\xe6\x96\x87-EN.pdf\"")
.unwrap();
assert_byte_string(&value);
}
#[test]
fn non_utf8_header_bytes_are_preserved() {
let value = HeaderValue::from_bytes(b"x\xff").unwrap();
assert_byte_string(&value);
}
#[test]
fn request_byte_string_is_sent_as_raw_bytes() {
let wire = b"attachment; filename=\"\xe4\xb8\xad\xe6\x96\x87-EN.pdf\"";
let byte_string = header_value_to_byte_string(&HeaderValue::from_bytes(wire).unwrap());
let value = byte_string_to_header_value(&byte_string).unwrap();
assert_eq!(value.as_bytes(), wire);
let value = byte_string_to_header_value("x\u{ff}").unwrap();
assert_eq!(value.as_bytes(), b"x\xff");
}
#[test]
fn request_non_byte_string_falls_back_to_utf8() {
let value = byte_string_to_header_value("中文").unwrap();
assert_eq!(value.as_bytes(), "中文".as_bytes());
}
}
+3 -1
View File
@@ -62,7 +62,9 @@ pub enum Error {
/// webview resource table.
#[error(transparent)]
Tauri(#[from] tauri::Error),
/// A response header value is not valid UTF-8, so it cannot be forwarded to the frontend.
// TODO(v3): remove this
/// A value is not valid UTF-8. No longer returned by this plugin: response header values are
/// forwarded to the frontend as byte strings.
#[error(transparent)]
Utf8(#[from] std::string::FromUtf8Error),
/// The frontend requested dangerous client settings, but the `dangerous-settings` Cargo