mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-18 23:07:27 +02:00
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:
1 parent
62b3565fb5
commit
0e17675653
6 files changed
+141
-7
No files matched your search
@@ -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.
|
||||
@@ -11,6 +11,9 @@
|
||||
"https://tauri.app",
|
||||
{
|
||||
"url": "http://localhost:3003"
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:3004"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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<FixtureServer> {
|
||||
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,
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user