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)
|
const queryDirectory = Array.isArray(req.query?.directory)
|
||||||
? req.query.directory[0]
|
? req.query.directory[0]
|
||||||
: req.query?.directory;
|
: req.query?.directory;
|
||||||
const requested = headerDirectory || queryDirectory || null;
|
const requested = [headerDirectory, queryDirectory].filter(Boolean);
|
||||||
|
|
||||||
if (requested) {
|
if (requested.length > 0) {
|
||||||
const validated = await validateDirectoryPath(requested);
|
let lastError = null;
|
||||||
if (!validated.ok) {
|
for (const candidate of requested) {
|
||||||
return { directory: null, error: validated.error };
|
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'
|
const readSettings = typeof getReadSettingsFromDiskMigrated === 'function'
|
||||||
@@ -119,18 +123,21 @@ export const createProjectDirectoryRuntime = (dependencies) => {
|
|||||||
const queryDirectory = Array.isArray(req.query?.directory)
|
const queryDirectory = Array.isArray(req.query?.directory)
|
||||||
? req.query.directory[0]
|
? req.query.directory[0]
|
||||||
: req.query?.directory;
|
: 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 };
|
return { directory: null, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validated = await validateDirectoryPath(requested);
|
let lastError = null;
|
||||||
if (!validated.ok) {
|
for (const candidate of requested) {
|
||||||
return { directory: null, error: validated.error };
|
const validated = await validateDirectoryPath(candidate);
|
||||||
|
if (validated.ok) {
|
||||||
|
return { directory: validated.directory, error: null };
|
||||||
|
}
|
||||||
|
lastError = validated.error;
|
||||||
}
|
}
|
||||||
|
return { directory: null, error: lastError };
|
||||||
return { directory: validated.directory, error: null };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -180,6 +180,28 @@ describe('project directory runtime', () => {
|
|||||||
expect(result).toEqual({ directory: rawPath, error: null });
|
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 () => {
|
it('resolves symlinks in query directory parameter', async () => {
|
||||||
const runtime = createTestRuntime({
|
const runtime = createTestRuntime({
|
||||||
fsPromises: {
|
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) => {
|
export const waitForSseDrain = (res, signal) => new Promise((resolve) => {
|
||||||
if (signal?.aborted || res.writableEnded || res.destroyed) {
|
if (signal?.aborted || res.writableEnded || res.destroyed) {
|
||||||
resolve();
|
resolve();
|
||||||
@@ -295,7 +314,9 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
? req.originalUrl
|
? req.originalUrl
|
||||||
: (typeof req.url === 'string' ? req.url : '');
|
: (typeof req.url === 'string' ? req.url : '');
|
||||||
const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
|
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.accept ??= 'text/event-stream';
|
||||||
headers['cache-control'] ??= 'no-cache';
|
headers['cache-control'] ??= 'no-cache';
|
||||||
|
|
||||||
@@ -414,7 +435,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
|
const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
|
||||||
const headers = req
|
const headers = req
|
||||||
? {
|
? {
|
||||||
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
|
...normalizeForwardedDirectoryHeaders(collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())),
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
'accept-encoding': 'identity',
|
'accept-encoding': 'identity',
|
||||||
}
|
}
|
||||||
@@ -654,6 +675,18 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
proxyReq.setHeader('Authorization', authHeaders.Authorization);
|
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.
|
// Defensive: request identity encoding from upstream OpenCode.
|
||||||
// This avoids compressed-body/header mismatches in multi-proxy setups.
|
// This avoids compressed-body/header mismatches in multi-proxy setups.
|
||||||
proxyReq.setHeader('accept-encoding', 'identity');
|
proxyReq.setHeader('accept-encoding', 'identity');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createDirectoryQueryCanonicalizer } from './proxy.js';
|
import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js';
|
||||||
|
|
||||||
describe('createDirectoryQueryCanonicalizer', () => {
|
describe('createDirectoryQueryCanonicalizer', () => {
|
||||||
it('canonicalizes directory query params and preserves other params', async () => {
|
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');
|
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