From efd621b087d5ab9806e6cf6ac09c0af02a8df2b1 Mon Sep 17 00:00:00 2001 From: FanFan4204 <212635410+FanFan4204@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:49:44 +0800 Subject: [PATCH] fix: handle non-ISO-8859-1 characters in fetch headers and Content-Disposition (#1673) * fix: handle non-ISO-8859-1 characters in fetch headers and Content-Disposition Browser Headers API rejects characters above U+00FF. The x-opencode-directory header carries raw filesystem paths, which breaks when paths contain Chinese/CJK characters. Also fixes Content-Disposition for non-ASCII filenames per RFC 5987. * refactor: export header sanitization helpers, deduplicate, add tests Export isLatin1Safe and sanitizeHeadersForBrowser from runtime-fetch.ts so VS Code webview can import them instead of duplicating the logic. Add tests: isLatin1Safe boundary checks, sanitizeHeadersForBrowser encoding/deduplication, runtimeFetch round-trip encode/decode, and Content-Disposition RFC 5987 output for both ASCII and non-ASCII filenames. * fix: mark encoded directory headers --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/lib/runtime-fetch.test.ts | 98 ++++++++++++++++++- packages/ui/src/lib/runtime-fetch.ts | 49 +++++++++- packages/vscode/webview/main.tsx | 11 ++- packages/web/server/lib/fs/routes.js | 8 +- packages/web/server/lib/fs/routes.test.js | 39 ++++++++ .../lib/opencode/project-directory-runtime.js | 16 ++- .../project-directory-runtime.test.js | 76 ++++++++++++++ 7 files changed, 289 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/lib/runtime-fetch.test.ts b/packages/ui/src/lib/runtime-fetch.test.ts index d034e7a1..1fe7c127 100644 --- a/packages/ui/src/lib/runtime-fetch.test.ts +++ b/packages/ui/src/lib/runtime-fetch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { createOpencodeClient } from '@opencode-ai/sdk/v2'; -import { buildRuntimeFetchUrl, runtimeFetch } from './runtime-fetch'; +import { buildRuntimeFetchUrl, isLatin1Safe, runtimeFetch, sanitizeHeadersForBrowser } from './runtime-fetch'; import { clearRuntimeAuthCredentialProvider, setRuntimeBearerToken } from './runtime-auth'; import { configureRuntimeUrlResolver, getRuntimeUrlResolver, setRuntimeUrlResolver } from './runtime-url'; @@ -360,3 +360,99 @@ describe('runtimeFetch read coalescing', () => { } }); }); + +describe('runtimeFetch header sanitization', () => { + test('isLatin1Safe returns true for Latin-1 strings', () => { + expect(isLatin1Safe('hello')).toBe(true); + expect(isLatin1Safe('/path/to/file.txt')).toBe(true); + expect(isLatin1Safe('')).toBe(true); + expect(isLatin1Safe('\u00FF')).toBe(true); + }); + + test('isLatin1Safe returns false for strings with characters above U+00FF', () => { + expect(isLatin1Safe('你好')).toBe(false); + expect(isLatin1Safe('D:\\文件')).toBe(false); + expect(isLatin1Safe('\u0100')).toBe(false); + }); + + test('sanitizeHeadersForBrowser encodes non-Latin-1 values in object form', () => { + const result = sanitizeHeadersForBrowser({ 'x-test': '你好' }); + expect(result).toBeTruthy(); + expect(result![0][0]).toBe('x-test'); + expect(result![0][1]).toBe(encodeURIComponent('你好')); + }); + + test('sanitizeHeadersForBrowser encodes non-Latin-1 values in array form', () => { + const result = sanitizeHeadersForBrowser([['x-test', 'こんにちは']]); + expect(result).toBeTruthy(); + expect(result![0][0]).toBe('x-test'); + expect(result![0][1]).toBe(encodeURIComponent('こんにちは')); + }); + + test('sanitizeHeadersForBrowser returns undefined when no encoding needed', () => { + const result = sanitizeHeadersForBrowser({ 'x-test': 'hello', accept: 'application/json' }); + expect(result).toBeFalsy(); + }); + + test('sanitizeHeadersForBrowser always encodes directory hints with marker', () => { + const path = 'C:\\work\\foo%20bar'; + const result = sanitizeHeadersForBrowser({ 'x-opencode-directory': path }); + expect(result).toBeTruthy(); + const encoded = Object.fromEntries(result!); + expect(encoded['x-opencode-directory']).toBe(encodeURIComponent(path)); + expect(encoded['x-opencode-directory-encoding']).toBe('uri'); + }); + + test('sanitizeHeadersForBrowser returns undefined for empty/undefined input', () => { + expect(sanitizeHeadersForBrowser(undefined)).toBeFalsy(); + expect(sanitizeHeadersForBrowser({})).toBeFalsy(); + }); + + test('sanitizeHeadersForBrowser only encodes non-Latin-1 values, leaves Latin-1 unchanged', () => { + const result = sanitizeHeadersForBrowser({ + accept: 'application/json', + 'x-chinese': '文件', + 'content-type': 'text/plain', + }); + expect(result).toBeTruthy(); + const encoded = Object.fromEntries(result!); + expect(encoded.accept).toBe('application/json'); + expect(encoded['content-type']).toBe('text/plain'); + expect(encoded['x-chinese']).toBe(encodeURIComponent('文件')); + }); + + test('runtimeFetch encodes directory request headers with marker', async () => { + const previous = getRuntimeUrlResolver(); + const originalWindow = globalThis.window; + const calls: Array<{ headers: Headers }> = []; + + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location: { origin: 'https://app.example', href: 'https://app.example/' } }, + }); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ headers: new Headers(init?.headers) }); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + + await runtimeFetch('/api/config/providers', { + headers: { 'x-opencode-directory': 'D:\\文件夹' }, + }); + + expect(calls).toHaveLength(1); + const encoded = calls[0].headers.get('x-opencode-directory'); + expect(encoded).not.toBe('D:\\文件夹'); + // decodeURIComponent round-trips back to original + expect(decodeURIComponent(encoded!)).toBe('D:\\文件夹'); + expect(calls[0].headers.get('x-opencode-directory-encoding')).toBe('uri'); + } finally { + setRuntimeUrlResolver(previous); + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); +}); diff --git a/packages/ui/src/lib/runtime-fetch.ts b/packages/ui/src/lib/runtime-fetch.ts index a5304a59..ddf8a44f 100644 --- a/packages/ui/src/lib/runtime-fetch.ts +++ b/packages/ui/src/lib/runtime-fetch.ts @@ -96,10 +96,55 @@ const shouldAttachRuntimeAuth = (input: string | URL | Request): boolean => { } }; +// Headers API only accepts ISO-8859-1 (Latin-1) characters. Any value containing +// characters outside \u0000-\u00FF causes "Failed to construct/set 'Headers': +// String contains non ISO-8859-1 code point." Encode those values so they round-trip +// safely through the browser's Headers API. Directory hints are always encoded +// with an explicit marker header so the server decodes only values produced by +// this transport and preserves literal percent sequences from direct clients. +export const isLatin1Safe = (value: string): boolean => { + for (let i = 0; i < value.length; i += 1) { + if (value.charCodeAt(i) > 0xFF) return false; + } + return true; +}; + +const shouldEncodeHeaderValue = (key: string, value: string): boolean => ( + key.toLowerCase() === 'x-opencode-directory' || !isLatin1Safe(value) +); + +export const sanitizeHeadersForBrowser = (init?: HeadersInit): [string, string][] | undefined => { + if (!init) return undefined; + // Normalize any HeadersInit shape into a plain array of entries so we can + // safely inspect and re-encode non-Latin-1 values. + const sourceEntries: [string, string][] = init instanceof Headers + ? Array.from(init.entries()) + : Array.isArray(init) + ? init + : Object.entries(init); + if (sourceEntries.length === 0) return undefined; + const entries: [string, string][] = []; + let dirty = false; + let encodedDirectoryHint = false; + for (const [key, value] of sourceEntries) { + if (shouldEncodeHeaderValue(key, value)) { + entries.push([key, encodeURIComponent(value)]); + dirty = true; + if (key.toLowerCase() === 'x-opencode-directory') encodedDirectoryHint = true; + } else { + entries.push([key, value]); + } + } + if (encodedDirectoryHint) { + entries.push(['x-opencode-directory-encoding', 'uri']); + } + return dirty ? entries : undefined; +}; + const mergeHeaders = async (inputHeaders?: HeadersInit, initHeaders?: HeadersInit, attachAuth = true): Promise => { - const headers = new Headers(inputHeaders); + const headers = new Headers(sanitizeHeadersForBrowser(inputHeaders) ?? inputHeaders); if (initHeaders) { - new Headers(initHeaders).forEach((value, key) => headers.set(key, value)); + new Headers(sanitizeHeadersForBrowser(initHeaders) ?? initHeaders).forEach((value, key) => headers.set(key, value)); } if (!attachAuth) { return headers; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 88a2f4c2..eec13f3e 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -4,6 +4,7 @@ import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve import { extractBodyBase64, extractBodyText, extractJsonBody, hasInitBody } from './requestBodyTransport'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; +import { sanitizeHeadersForBrowser } from '@openchamber/ui/lib/runtime-fetch'; import { buildVSCodeThemeFromPalette, readVSCodeThemePalette, @@ -279,7 +280,7 @@ const normalizeUrl = (input: string | URL) => { const headersToRecord = (headers: HeadersInit | undefined): Record => { if (!headers) return {}; - const normalized = headers instanceof Headers ? headers : new Headers(headers); + const normalized = new Headers(sanitizeHeadersForBrowser(headers) ?? headers); const result: Record = {}; normalized.forEach((value, key) => { result[key] = value; @@ -297,8 +298,14 @@ const getRequestDirectoryHint = (url: URL, input?: RequestInfo | URL, init?: Req const queryDirectory = url.searchParams.get('directory') || undefined; if (queryDirectory) return queryDirectory; const headers = getRequestHeaders(input, init); + const directoryEncoding = Object.entries(headers).find(([key]) => key.toLowerCase() === 'x-opencode-directory-encoding')?.[1]; for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === 'x-opencode-directory') return value; + if (key.toLowerCase() === 'x-opencode-directory') { + // headersToRecord marks encoded directory hints so direct/raw percent + // sequences from other callers are not decoded accidentally. + if (directoryEncoding !== 'uri') return value; + try { return decodeURIComponent(value); } catch { return value; } + } } return undefined; }; diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index b9cbc783..81a29d8e 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -883,7 +883,13 @@ export const registerFsRoutes = (app, dependencies) => { const download = req.query.download === 'true'; if (download) { const fileName = path.basename(canonicalPath); - res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + // RFC 5987: use filename*= for non-ASCII filenames, with ASCII-only + // filename= as fallback for older clients. + const asciiOnly = fileName.replace(/[^\u0000-\u007F]/g, ''); + const fallback = asciiOnly || 'file'; + // Percent-encode the raw UTF-8 bytes for filename*= + const encoded = encodeURIComponent(fileName); + res.setHeader('Content-Disposition', `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`); } const content = await fsPromises.readFile(canonicalPath); diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index edef9b70..3d815e78 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -596,3 +596,42 @@ describe('fs exec git-read cache', () => { expect(calls.length).toBe(afterFill + 2); }); }); + +describe('fs raw download Content-Disposition', () => { + it('uses RFC 5987 filename*= encoding for non-ASCII filenames on download', async () => { + const fsPromises = { + realpath: vi.fn(async (targetPath) => targetPath), + stat: vi.fn(async () => ({ isFile: () => true, size: 6 })), + readFile: vi.fn(async () => Buffer.from('content')), + }; + const handler = registerRaw(fsPromises); + + const res = await callRaw(handler, { + path: '/repo/文件.txt', + download: 'true', + }); + + expect(res.statusCode).toBe(200); + const cd = res.getHeader('content-disposition'); + expect(cd).toContain("filename*=UTF-8''"); + expect(cd).toContain(encodeURIComponent('文件.txt')); + // ASCII fallback strips non-ASCII chars, leaving extension + expect(cd).toContain('filename=".txt"'); + }); + + it('uses plain filename for ASCII-only filenames on download', async () => { + const fsPromises = { + realpath: vi.fn(async (targetPath) => targetPath), + stat: vi.fn(async () => ({ isFile: () => true, size: 6 })), + readFile: vi.fn(async () => Buffer.from('content')), + }; + const handler = registerRaw(fsPromises); + + const res = await callRaw(handler, { path: '/repo/readme.txt', download: 'true' }); + + expect(res.statusCode).toBe(200); + const cd = res.getHeader('content-disposition'); + expect(cd).toContain('filename="readme.txt"'); + expect(cd).toContain("filename*=UTF-8''readme.txt"); + }); +}); diff --git a/packages/web/server/lib/opencode/project-directory-runtime.js b/packages/web/server/lib/opencode/project-directory-runtime.js index 22248e5e..b6c90347 100644 --- a/packages/web/server/lib/opencode/project-directory-runtime.js +++ b/packages/web/server/lib/opencode/project-directory-runtime.js @@ -1,5 +1,13 @@ import { createRealpathCache } from '../path-realpath-cache.js'; +// Browser transport percent-encodes directory hints and marks them explicitly. +// Only marked values are decoded so literal percent sequences from direct API +// clients are preserved. +const safeDecodeMarkedURIComponent = (value, encoding) => { + if (encoding !== 'uri') return value; + try { return decodeURIComponent(value); } catch { return value; } +}; + export const createProjectDirectoryRuntime = (dependencies) => { const { fsPromises, @@ -50,7 +58,9 @@ export const createProjectDirectoryRuntime = (dependencies) => { }; const resolveProjectDirectory = async (req) => { - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null; + const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null; const queryDirectory = Array.isArray(req.query?.directory) ? req.query.directory[0] : req.query?.directory; @@ -103,7 +113,9 @@ export const createProjectDirectoryRuntime = (dependencies) => { }; const resolveOptionalProjectDirectory = async (req) => { - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null; + const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null; const queryDirectory = Array.isArray(req.query?.directory) ? req.query.directory[0] : req.query?.directory; diff --git a/packages/web/server/lib/opencode/project-directory-runtime.test.js b/packages/web/server/lib/opencode/project-directory-runtime.test.js index b02be6d8..94b57b1a 100644 --- a/packages/web/server/lib/opencode/project-directory-runtime.test.js +++ b/packages/web/server/lib/opencode/project-directory-runtime.test.js @@ -128,6 +128,58 @@ describe('project directory runtime', () => { expect(result).toEqual({ directory: '/real/workspace/project', error: null }); }); + it('decodes marked x-opencode-directory header values', async () => { + const pathWithUnicode = '/home/user/测试项目'; + let validatedPath = null; + const runtime = createTestRuntime({ + fsPromises: { + stat: async (p) => { + validatedPath = p; + return { isDirectory: () => true }; + }, + realpath: async (p) => p, + }, + }); + + const req = { + get: (header) => { + if (header === 'x-opencode-directory') return encodeURIComponent(pathWithUnicode); + if (header === 'x-opencode-directory-encoding') return 'uri'; + return null; + }, + query: {}, + }; + + const result = await runtime.resolveProjectDirectory(req); + + expect(validatedPath).toBe(pathWithUnicode); + expect(result).toEqual({ directory: pathWithUnicode, error: null }); + }); + + it('preserves raw percent sequences without directory encoding marker', async () => { + const rawPath = '/home/user/foo%20bar'; + let validatedPath = null; + const runtime = createTestRuntime({ + fsPromises: { + stat: async (p) => { + validatedPath = p; + return { isDirectory: () => true }; + }, + realpath: async (p) => p, + }, + }); + + const req = { + get: (header) => header === 'x-opencode-directory' ? rawPath : null, + query: {}, + }; + + const result = await runtime.resolveProjectDirectory(req); + + expect(validatedPath).toBe(rawPath); + expect(result).toEqual({ directory: rawPath, error: null }); + }); + it('resolves symlinks in query directory parameter', async () => { const runtime = createTestRuntime({ fsPromises: { @@ -222,5 +274,29 @@ describe('project directory runtime', () => { expect(result).toEqual({ directory: '/real/workspace/project', error: null }); }); + + it('preserves raw percent sequences without directory encoding marker', async () => { + const rawPath = '/optional/foo%25bar'; + let validatedPath = null; + const runtime = createTestRuntime({ + fsPromises: { + stat: async (p) => { + validatedPath = p; + return { isDirectory: () => true }; + }, + realpath: async (p) => p, + }, + }); + + const req = { + get: (header) => header === 'x-opencode-directory' ? rawPath : null, + query: {}, + }; + + const result = await runtime.resolveOptionalProjectDirectory(req); + + expect(validatedPath).toBe(rawPath); + expect(result).toEqual({ directory: rawPath, error: null }); + }); }); });