From 38f7f67fe7a6ac5e606e52c4bc53a706c88f13ee Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Sat, 18 Jul 2026 19:22:02 +0000 Subject: [PATCH] fix: resolve issue #74 - CORS allowOrigin silently leaks configured origin on mismatch When a specific CORS origin is configured and the incoming request origin does not match, the previous code returned the configured origin in the Access-Control-Allow-Origin header. This leaks the configured origin to untrusted callers and does not actually block the request (browsers reject the response because the header value doesn't match the request origin, but the information is still exposed). The fix omits the Access-Control-Allow-Origin header entirely on a mismatch so that neither the configured origin is disclosed nor is there any ambiguity about whether cross-origin access is granted. Tests are updated to assert the header is absent on mismatch rather than checking that it equals the configured (but leaked) origin. --- index.js | 11 +++++++++-- index.test.js | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index d55980c..10d1791 100644 --- a/index.js +++ b/index.js @@ -16,16 +16,23 @@ function corsHeaders(request) { const requestedMethod = request?.headers.get("access-control-request-method") const requestedPrivateNetwork = request?.headers.get("access-control-request-private-network") const requestOrigin = request?.headers.get("origin") ?? "" - const allowOrigin = configuredOrigin === "*" ? "*" : (requestOrigin === configuredOrigin ? requestOrigin : configuredOrigin) + // When a specific origin is configured, only echo it back when the request + // origin matches exactly. On a mismatch we omit the header entirely so that + // browsers block the cross-origin request and the configured origin is not + // disclosed to untrusted callers. + const allowOrigin = configuredOrigin === "*" ? "*" : (requestOrigin === configuredOrigin ? requestOrigin : null) const headers = { vary: "origin, access-control-request-method, access-control-request-headers", - "access-control-allow-origin": allowOrigin, "access-control-allow-headers": requestedHeaders ?? "authorization, content-type, x-opencode-provider", "access-control-allow-methods": requestedMethod ?? "GET, POST, OPTIONS", "access-control-max-age": "86400", } + if (allowOrigin !== null) { + headers["access-control-allow-origin"] = allowOrigin + } + if (requestedPrivateNetwork === "true") { headers["access-control-allow-private-network"] = "true" } diff --git a/index.test.js b/index.test.js index 49d5130..0129ada 100644 --- a/index.test.js +++ b/index.test.js @@ -162,14 +162,14 @@ test("health response includes CORS headers", async () => { assert.deepEqual(body, { healthy: true, service: "opencode-openai-proxy" }) }) -test("configured origin is returned for normal requests", async () => { +test("configured origin is returned when request origin matches", async () => { process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN = "https://console.example.com" try { const handler = createProxyFetchHandler(createClient()) const request = new Request("http://127.0.0.1:4010/health", { headers: { - Origin: "https://app.example.com", + Origin: "https://console.example.com", }, }) @@ -181,7 +181,7 @@ test("configured origin is returned for normal requests", async () => { } }) -test("disallowed origin does not receive its own origin back", async () => { +test("disallowed origin does not receive access-control-allow-origin header", async () => { process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN = "https://allowed.example.com" try { @@ -192,9 +192,9 @@ test("disallowed origin does not receive its own origin back", async () => { const response = await handler(request) - // The header must be the configured origin, not the request's origin - assert.equal(response.headers.get("access-control-allow-origin"), "https://allowed.example.com") - assert.notEqual(response.headers.get("access-control-allow-origin"), "https://evil.example.com") + // On an origin mismatch the header must be absent so neither the + // configured origin is leaked nor the browser grants cross-origin access. + assert.equal(response.headers.get("access-control-allow-origin"), null) } finally { delete process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN } @@ -211,7 +211,7 @@ test("request with no Origin header is handled gracefully", async () => { assert.equal(response.headers.get("access-control-allow-origin"), "*") }) -test("OPTIONS preflight for disallowed origin returns configured origin, not request origin", async () => { +test("OPTIONS preflight for disallowed origin omits access-control-allow-origin header", async () => { process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN = "https://allowed.example.com" try { @@ -227,8 +227,9 @@ test("OPTIONS preflight for disallowed origin returns configured origin, not req const response = await handler(request) assert.equal(response.status, 204) - assert.equal(response.headers.get("access-control-allow-origin"), "https://allowed.example.com") - assert.notEqual(response.headers.get("access-control-allow-origin"), "https://evil.example.com") + // The configured origin must not be disclosed on a mismatch; the header + // should be absent so the preflight is rejected by the browser. + assert.equal(response.headers.get("access-control-allow-origin"), null) } finally { delete process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN }