fix(server): normalize encoded directory headers
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user