diff --git a/.changes/http-non-utf8-headers.md b/.changes/http-non-utf8-headers.md new file mode 100644 index 000000000..7d241296f --- /dev/null +++ b/.changes/http-non-utf8-headers.md @@ -0,0 +1,6 @@ +--- +"http": patch +"http-js": patch +--- + +Fix `fetch` throwing a `TypeError` when a response header value contains non-ASCII bytes (e.g. a UTF-8 encoded `Content-Disposition` filename). Header values are now treated as byte strings like in a browser: response header values are decoded as ISO-8859-1 so the raw bytes are preserved as a valid `ByteString` for `new Headers()`, and request header values are sent as their raw bytes (one byte per character) instead of being UTF-8 encoded. A request header value with a character such as `é` is now sent as the single byte `0xE9`, as browsers do. diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json index 51bf9fd02..56c25550e 100644 --- a/examples/api/src-tauri/capabilities/base.json +++ b/examples/api/src-tauri/capabilities/base.json @@ -11,6 +11,9 @@ "https://tauri.app", { "url": "http://localhost:3003" + }, + { + "url": "http://127.0.0.1:3004" } ] }, diff --git a/packages/api-e2e/test/helpers/server.ts b/packages/api-e2e/test/helpers/server.ts index 230d92df3..1e76ee1a9 100644 --- a/packages/api-e2e/test/helpers/server.ts +++ b/packages/api-e2e/test/helpers/server.ts @@ -24,6 +24,12 @@ export const UPDATER_FIXTURE_OLDER_VERSION = '1.0.0' export const DOWNLOAD_FIXTURE_BODY = 'hello from the plugins e2e fixture server\n'.repeat(64) +/** + * Filename `GET /raw-header` puts in its `content-disposition`, sent as raw + * UTF-8 bytes on the wire as many servers do. + */ +export const RAW_HEADER_FILENAME = '中文-EN.pdf' + /** WebSocket endpoint of the fixture server (the `ws://` twin of {@link FIXTURE_SERVER_URL}). */ export const WEBSOCKET_FIXTURE_URL = `ws://127.0.0.1:${FIXTURE_SERVER_PORT}/ws` /** Text message that makes the `/ws` endpoint close the connection with {@link WEBSOCKET_CLOSE_CODE}. */ @@ -45,7 +51,10 @@ export interface FixtureServer { * {@link UPDATER_TARGET_NO_UPDATE}. * - `GET /download` — {@link DOWNLOAD_FIXTURE_BODY} with a `Content-Length`. * - `* /echo` — a JSON description of the request (`method`, `url`, `headers` - * and the utf-8 `body`). + * and the utf-8 `body`). Node decodes header values as ISO-8859-1, one char + * per byte, so `headers` shows the exact bytes the client sent. + * - `GET /raw-header` — an empty response whose `content-disposition` names + * {@link RAW_HEADER_FILENAME} in raw UTF-8 bytes. * - `ws /ws` — a WebSocket echo endpoint: text and binary messages are sent * back as-is, and {@link WEBSOCKET_CLOSE_REQUEST} makes the server close the * connection with {@link WEBSOCKET_CLOSE_CODE}. On `/ws/headers` the server @@ -91,6 +100,20 @@ export function startFixtureServer(): Promise { return } + if (route === 'raw-header' && req.method === 'GET') { + // Node writes header strings as ISO-8859-1, so this sends the UTF-8 bytes as-is. + const disposition = Buffer.from( + `attachment; filename="${RAW_HEADER_FILENAME}"` + ).toString('latin1') + res + .writeHead(200, { + 'content-disposition': disposition, + 'content-length': 0 + }) + .end() + return + } + if (route === 'echo') { json(res, { method: req.method, diff --git a/packages/api-e2e/test/specs/http.spec.ts b/packages/api-e2e/test/specs/http.spec.ts index cd283200a..5506227ca 100644 --- a/packages/api-e2e/test/specs/http.spec.ts +++ b/packages/api-e2e/test/specs/http.spec.ts @@ -4,6 +4,7 @@ import { expect } from '@wdio/globals' import { tauri, tauriError, describePlugin } from '../helpers/index.js' +import { FIXTURE_SERVER_URL, RAW_HEADER_FILENAME } from '../helpers/server.js' // The example spawns an echo server on this port: it replies with the request // body and the request headers, and sets a `session-token` cookie on requests @@ -93,6 +94,40 @@ describePlugin('http', () => { expect(body.text).toContain('qux') }) + it('fetch exposes non-ASCII response header bytes as a ByteString', async () => { + // https://github.com/tauri-apps/plugins-workspace/issues/2898 + const result = await tauri(async (api, url) => { + const response = await api.http.fetch(`${url}/raw-header`) + await response.text() + const value = response.headers.get('content-disposition') ?? '' + // A ByteString holds one byte per char, like a browser exposes it. + const bytes = Uint8Array.from(value, (c) => c.charCodeAt(0)) + return new TextDecoder().decode(bytes) + }, FIXTURE_SERVER_URL) + expect(result).toBe(`attachment; filename="${RAW_HEADER_FILENAME}"`) + }) + + it('fetch sends ByteString request header values as raw bytes', async () => { + // Each char is one byte (0x80-0xFF) on the wire, as in a browser. The + // tail is the UTF-8 encoding of `中`, as when a response header is sent back. + const value = 'x\u00e4\u00ff\u00e4\u00b8\u00ad' + const echoed = await tauri( + async (api, url, value) => { + const response = await api.http.fetch(`${url}/echo`, { + headers: { 'x-e2e-raw': value } + }) + const body = (await response.json()) as { + headers: Record + } + return body.headers['x-e2e-raw'] + }, + FIXTURE_SERVER_URL, + value + ) + // the fixture server decodes header values as ISO-8859-1 + expect(echoed).toBe(value) + }) + it('the cookie jar stores and replays cookies across requests', async () => { const result = await tauri(async (api, url) => { // The jar is persisted in the app data dir, so an earlier run (or the diff --git a/plugins/http/src/commands.rs b/plugins/http/src/commands.rs index fe5c3bcde..44af5bd54 100644 --- a/plugins/http/src/commands.rs +++ b/plugins/http/src/commands.rs @@ -267,7 +267,7 @@ pub async fn fetch( 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(webview: Webview, 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 { + let bytes = value + .chars() + .map(|c| u8::try_from(c).ok()) + .collect::>>(); + Ok(match bytes { + Some(bytes) => HeaderValue::from_bytes(&bytes)?, + None => HeaderValue::from_str(value)?, + }) +} + #[command] pub async fn fetch_send( webview: Webview, @@ -458,10 +478,7 @@ pub async fn fetch_send( 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::>(), + 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()); + } } diff --git a/plugins/http/src/error.rs b/plugins/http/src/error.rs index 2b974e692..3f68f1cdb 100644 --- a/plugins/http/src/error.rs +++ b/plugins/http/src/error.rs @@ -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