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

+24 -1
View File
@@ -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,
+35
View File
@@ -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