fix(server): normalize encoded directory headers

This commit is contained in:
Bohdan Triapitsyn
2026-06-24 00:43:11 +03:00
parent c3cf914fda
commit 08b866136e
4 changed files with 101 additions and 16 deletions
@@ -64,14 +64,18 @@ export const createProjectDirectoryRuntime = (dependencies) => {
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
const requested = [headerDirectory, queryDirectory].filter(Boolean);
if (requested) {
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
if (requested.length > 0) {
let lastError = null;
for (const candidate of requested) {
const validated = await validateDirectoryPath(candidate);
if (validated.ok) {
return { directory: validated.directory, error: null };
}
lastError = validated.error;
}
return { directory: validated.directory, error: null };
return { directory: null, error: lastError };
}
const readSettings = typeof getReadSettingsFromDiskMigrated === 'function'
@@ -119,18 +123,21 @@ export const createProjectDirectoryRuntime = (dependencies) => {
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
const requested = [headerDirectory, queryDirectory].filter(Boolean);
if (!requested) {
if (requested.length === 0) {
return { directory: null, error: null };
}
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
let lastError = null;
for (const candidate of requested) {
const validated = await validateDirectoryPath(candidate);
if (validated.ok) {
return { directory: validated.directory, error: null };
}
lastError = validated.error;
}
return { directory: validated.directory, error: null };
return { directory: null, error: lastError };
};
return {
@@ -180,6 +180,28 @@ describe('project directory runtime', () => {
expect(result).toEqual({ directory: rawPath, error: null });
});
it('falls back to query directory when an unmarked encoded header is invalid', async () => {
const validPath = '/home/user/workspace/project';
const runtime = createTestRuntime({
fsPromises: {
stat: async (p) => {
if (p === validPath) return { isDirectory: () => true };
throw { code: 'ENOENT' };
},
realpath: async (p) => p,
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? encodeURIComponent(validPath) : null,
query: { directory: validPath },
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: validPath, error: null });
});
it('resolves symlinks in query directory parameter', async () => {
const runtime = createTestRuntime({
fsPromises: {
+35 -2
View File
@@ -31,6 +31,25 @@ export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions }
};
};
export const normalizeForwardedDirectoryHeaders = (headers) => {
const rawDirectory = headers?.['x-opencode-directory'];
if (typeof rawDirectory !== 'string') {
return headers;
}
if (headers['x-opencode-directory-encoding'] !== 'uri') {
return headers;
}
try {
headers['x-opencode-directory'] = decodeURIComponent(rawDirectory);
} catch {
// Leave malformed values untouched; upstream will reject invalid paths.
}
delete headers['x-opencode-directory-encoding'];
return headers;
};
export const waitForSseDrain = (res, signal) => new Promise((resolve) => {
if (signal?.aborted || res.writableEnded || res.destroyed) {
resolve();
@@ -295,7 +314,9 @@ export const registerOpenCodeProxy = (app, deps) => {
? req.originalUrl
: (typeof req.url === 'string' ? req.url : '');
const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
const headers = collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders());
const headers = normalizeForwardedDirectoryHeaders(
collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())
);
headers.accept ??= 'text/event-stream';
headers['cache-control'] ??= 'no-cache';
@@ -414,7 +435,7 @@ export const registerOpenCodeProxy = (app, deps) => {
const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
const headers = req
? {
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
...normalizeForwardedDirectoryHeaders(collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())),
accept: 'application/json',
'accept-encoding': 'identity',
}
@@ -654,6 +675,18 @@ export const registerOpenCodeProxy = (app, deps) => {
proxyReq.setHeader('Authorization', authHeaders.Authorization);
}
if (req.headers?.['x-opencode-directory-encoding'] === 'uri') {
const rawDirectory = req.headers['x-opencode-directory'];
if (typeof rawDirectory === 'string') {
try {
proxyReq.setHeader('x-opencode-directory', decodeURIComponent(rawDirectory));
} catch {
proxyReq.setHeader('x-opencode-directory', rawDirectory);
}
}
proxyReq.removeHeader?.('x-opencode-directory-encoding');
}
// Defensive: request identity encoding from upstream OpenCode.
// This avoids compressed-body/header mismatches in multi-proxy setups.
proxyReq.setHeader('accept-encoding', 'identity');
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createDirectoryQueryCanonicalizer } from './proxy.js';
import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js';
describe('createDirectoryQueryCanonicalizer', () => {
it('canonicalizes directory query params and preserves other params', async () => {
@@ -70,3 +70,26 @@ describe('createDirectoryQueryCanonicalizer', () => {
await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1');
});
});
describe('normalizeForwardedDirectoryHeaders', () => {
it('decodes marked directory headers before forwarding to OpenCode', () => {
const headers = normalizeForwardedDirectoryHeaders({
'x-opencode-directory': encodeURIComponent('/Users/example/project'),
'x-opencode-directory-encoding': 'uri',
});
expect(headers).toEqual({
'x-opencode-directory': '/Users/example/project',
});
});
it('preserves unmarked percent sequences from direct clients', () => {
const headers = normalizeForwardedDirectoryHeaders({
'x-opencode-directory': '/Users/example/project%20literal',
});
expect(headers).toEqual({
'x-opencode-directory': '/Users/example/project%20literal',
});
});
});