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:
FanFan4204
2026-06-23 19:49:44 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 43f677d56d
commit efd621b087
7 changed files with 289 additions and 8 deletions
+7 -1
View File
@@ -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);
+39
View File
@@ -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 });
});
});
});