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 <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
43f677d56d
commit
efd621b087
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Headers> => {
|
||||
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;
|
||||
|
||||
@@ -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<string, string> => {
|
||||
if (!headers) return {};
|
||||
const normalized = headers instanceof Headers ? headers : new Headers(headers);
|
||||
const normalized = new Headers(sanitizeHeadersForBrowser(headers) ?? headers);
|
||||
const result: Record<string, string> = {};
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user