From 4e8f946957400f168cf80bff271769dd0ff26c13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:43:44 +0000 Subject: [PATCH 1/4] Initial plan From 11a2ae9c4e20bad4e9d35dc70e2ab348834bc20b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:47:43 +0000 Subject: [PATCH 2/4] fix(api-proxy): route unversioned openai responses via /v1 Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/10848884-d3b5-42d4-8299-a1a5c2cc46d8 --- containers/api-proxy/server.js | 24 ++++++++++++++++++++---- containers/api-proxy/server.test.js | 17 ++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 010ba5f1..a6510373 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -149,10 +149,12 @@ function normalizeBasePath(rawPath) { /** * Build the full upstream path by joining basePath, reqUrl's pathname, and query string. + * Applies provider-safe defaults and avoids duplicate prefixing when the incoming + * path already includes the configured base path. * * Examples: - * buildUpstreamPath('/v1/chat/completions', 'api.openai.com', '') - * → '/v1/chat/completions' + * buildUpstreamPath('/responses', 'api.openai.com', '') + * → '/v1/responses' * buildUpstreamPath('/v1/chat/completions', 'host.databricks.com', '/serving-endpoints') * → '/serving-endpoints/v1/chat/completions' * buildUpstreamPath('/v1/messages?stream=true', 'host.com', '/anthropic') @@ -165,8 +167,22 @@ function normalizeBasePath(rawPath) { */ function buildUpstreamPath(reqUrl, targetHost, basePath) { const targetUrl = new URL(reqUrl, `https://${targetHost}`); - const prefix = basePath === '/' ? '' : basePath; - return prefix + targetUrl.pathname + targetUrl.search; + const pathname = targetUrl.pathname; + let prefix = basePath === '/' ? '' : basePath; + + // OpenAI's canonical API paths are versioned under /v1, while some newer + // clients (for example Codex CLI with OPENAI_BASE_URL pointing at the sidecar) + // send unversioned paths like /responses. Add /v1 only for the default + // OpenAI host when no explicit base path is configured. + if (!prefix && targetHost === 'api.openai.com') { + prefix = '/v1'; + } + + if (prefix && (pathname === prefix || pathname.startsWith(`${prefix}/`))) { + return pathname + targetUrl.search; + } + + return prefix + pathname + targetUrl.search; } /** diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index a83617a4..549160c8 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -406,6 +406,16 @@ describe('buildUpstreamPath', () => { .toBe('/v1/chat/completions'); }); + it('should map unversioned /responses to /v1/responses for api.openai.com', () => { + expect(buildUpstreamPath('/responses', 'api.openai.com', '')) + .toBe('/v1/responses'); + }); + + it('should preserve already-versioned OpenAI responses path', () => { + expect(buildUpstreamPath('/v1/responses', 'api.openai.com', '')) + .toBe('/v1/responses'); + }); + it('should preserve /v1/messages exactly (Anthropic standard path)', () => { expect(buildUpstreamPath('/v1/messages', 'api.anthropic.com', '')) .toBe('/v1/messages'); @@ -438,6 +448,12 @@ describe('buildUpstreamPath', () => { .toBe('/v1/messages'); }); + it('should not force /v1 for non-OpenAI custom targets', () => { + const target = 'my-gateway.example.com'; + expect(buildUpstreamPath('/responses', target, '')) + .toBe('/responses'); + }); + it('should produce wrong hostname if scheme is NOT stripped (demonstrating the bug)', () => { // Without normalizeApiTarget, the scheme-prefixed value causes // new URL() to parse 'https' as the hostname instead of the real host @@ -946,4 +962,3 @@ describe('resolveOpenCodeRoute', () => { expect(route.headers['x-api-key']).toBeUndefined(); }); }); - From aeb6845256209f47a068453c7051a3f33b211108 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:49:16 +0000 Subject: [PATCH 3/4] fix(api-proxy): normalize openai host before default /v1 mapping Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/10848884-d3b5-42d4-8299-a1a5c2cc46d8 --- containers/api-proxy/server.js | 12 ++++++++++-- containers/api-proxy/server.test.js | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index a6510373..b7dcc293 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -174,8 +174,16 @@ function buildUpstreamPath(reqUrl, targetHost, basePath) { // clients (for example Codex CLI with OPENAI_BASE_URL pointing at the sidecar) // send unversioned paths like /responses. Add /v1 only for the default // OpenAI host when no explicit base path is configured. - if (!prefix && targetHost === 'api.openai.com') { - prefix = '/v1'; + if (!prefix) { + let normalizedTargetHost = targetHost; + try { + normalizedTargetHost = new URL(`https://${targetHost}`).hostname; + } catch { + // Fall back to the raw host value if parsing fails. + } + if (normalizedTargetHost === 'api.openai.com') { + prefix = '/v1'; + } } if (prefix && (pathname === prefix || pathname.startsWith(`${prefix}/`))) { diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 549160c8..89c8c1b2 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -416,6 +416,11 @@ describe('buildUpstreamPath', () => { .toBe('/v1/responses'); }); + it('should map unversioned /responses to /v1/responses when OpenAI host includes port', () => { + expect(buildUpstreamPath('/responses', 'api.openai.com:443', '')) + .toBe('/v1/responses'); + }); + it('should preserve /v1/messages exactly (Anthropic standard path)', () => { expect(buildUpstreamPath('/v1/messages', 'api.anthropic.com', '')) .toBe('/v1/messages'); From d4ab4332e96d79b26377bf6af1e7dde95671ba7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:00:26 +0000 Subject: [PATCH 4/4] fix(api-proxy): reject protocol-relative request urls Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/eb1cf84f-e932-421b-89f7-5950554b86cc --- containers/api-proxy/server.js | 22 +++++++++------------- containers/api-proxy/server.test.js | 12 ++++++++++++ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index b7dcc293..829b632d 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -160,12 +160,16 @@ function normalizeBasePath(rawPath) { * buildUpstreamPath('/v1/messages?stream=true', 'host.com', '/anthropic') * → '/anthropic/v1/messages?stream=true' * - * @param {string} reqUrl - The incoming request URL (must start with '/') + * @param {string} reqUrl - The incoming request URL (must start with '/' and not '//') * @param {string} targetHost - The upstream hostname (used only to parse the URL) * @param {string} basePath - Normalized base path prefix (e.g. '/serving-endpoints' or '') * @returns {string} Full upstream path including query string */ function buildUpstreamPath(reqUrl, targetHost, basePath) { + if (typeof reqUrl !== 'string' || !reqUrl.startsWith('/') || reqUrl.startsWith('//')) { + throw new Error('URL must be a relative origin-form path'); + } + const targetUrl = new URL(reqUrl, `https://${targetHost}`); const pathname = targetUrl.pathname; let prefix = basePath === '/' ? '' : basePath; @@ -174,16 +178,8 @@ function buildUpstreamPath(reqUrl, targetHost, basePath) { // clients (for example Codex CLI with OPENAI_BASE_URL pointing at the sidecar) // send unversioned paths like /responses. Add /v1 only for the default // OpenAI host when no explicit base path is configured. - if (!prefix) { - let normalizedTargetHost = targetHost; - try { - normalizedTargetHost = new URL(`https://${targetHost}`).hostname; - } catch { - // Fall back to the raw host value if parsing fails. - } - if (normalizedTargetHost === 'api.openai.com') { - prefix = '/v1'; - } + if (!prefix && targetUrl.hostname === 'api.openai.com') { + prefix = '/v1'; } if (prefix && (pathname === prefix || pathname.startsWith(`${prefix}/`))) { @@ -442,7 +438,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = }); // Validate that req.url is a relative path (prevent open-redirect / SSRF) - if (!req.url || !req.url.startsWith('/')) { + if (!req.url || !req.url.startsWith('/') || req.url.startsWith('//')) { const duration = Date.now() - startTime; metrics.gaugeDec('active_requests', { provider }); metrics.increment('requests_total', { provider, method: req.method, status_class: '4xx' }); @@ -711,7 +707,7 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, } // ── Validate: relative path only (prevent SSRF) ──────────────────────── - if (!req.url || !req.url.startsWith('/')) { + if (!req.url || !req.url.startsWith('/') || req.url.startsWith('//')) { logRequest('warn', 'websocket_upgrade_rejected', { request_id: requestId, provider, diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 89c8c1b2..60b08865 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -347,6 +347,11 @@ describe('buildUpstreamPath', () => { it('should handle root path with no base path', () => { expect(buildUpstreamPath('/', HOST, '')).toBe('/'); }); + + it('should reject protocol-relative URLs to prevent host override', () => { + expect(() => buildUpstreamPath('//evil.com/v1/chat/completions', HOST, '')) + .toThrow('URL must be a relative origin-form path'); + }); }); describe('Databricks serving-endpoints (single-segment base path)', () => { @@ -596,6 +601,13 @@ describe('proxyWebSocket', () => { expect(socket.destroy).toHaveBeenCalled(); }); + it('rejects a protocol-relative URL with 400 (SSRF prevention)', () => { + const socket = makeMockSocket(); + proxyWebSocket(makeUpgradeReq({ url: '//evil.com/v1/responses' }), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai'); + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 400 Bad Request')); + expect(socket.destroy).toHaveBeenCalled(); + }); + it('rejects a null URL with 400', () => { const socket = makeMockSocket(); proxyWebSocket(makeUpgradeReq({ url: null }), socket, Buffer.alloc(0), 'api.openai.com', {}, 'openai');