From f8a86518ec43ebe90b74ad0790830ca5c3a3fe61 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 18:46:32 -0700 Subject: [PATCH] fix(proxy): remove unauthenticated _proxy_debug disclosure endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/_proxy_debug answered any caller with a 10-char prefix of LANGSMITH_API_KEY, the upstream LangGraph deployment URL, and environment facts (hasDatabaseUrl, instanceId). It returned before the rate-limit and body-size gates, and the origin allowlist above it only rejects when an Origin header is present — so a plain curl reached it on both examples.threadplane.ai and demo.threadplane.ai. Nothing in CI, tests, or runbooks used it; the only references are historical plan docs. The path now proxies upstream like any other. Adds a regression test, mutation-verified to fail against the removed code. Co-Authored-By: Claude Opus 5 --- scripts/langgraph-proxy.spec.ts | 24 ++++++++++++++++++++++++ scripts/langgraph-proxy.ts | 23 ----------------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/scripts/langgraph-proxy.spec.ts b/scripts/langgraph-proxy.spec.ts index 8ba48594e..09b45307b 100644 --- a/scripts/langgraph-proxy.spec.ts +++ b/scripts/langgraph-proxy.spec.ts @@ -150,6 +150,30 @@ describe('createProxyHandler', () => { expect(res._status).toBe(200); }); + // Regression: `/_proxy_debug` was an unauthenticated early-return that + // disclosed a prefix of LANGSMITH_API_KEY, the upstream deployment URL, and + // environment facts to any caller. It sat above the origin/rate-limit gates, + // and the origin allowlist only rejects when an Origin header is present, so + // a plain curl reached it on production. It must stay a normal proxied path. + it('does not answer /_proxy_debug locally or disclose key material', async () => { + const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue( + new Response('{"detail":"Not Found"}', { status: 404, headers: { 'content-type': 'application/json' } }), + ); + const handler = createProxyHandler({ backendUrl: DEFAULT_BACKEND }); + const res = makeRes(); + await handler({ method: 'GET', headers: { host: 'demo.threadplane.ai' }, url: '/api/_proxy_debug', query: {} } as never, res as never); + + // Forwarded upstream like any other path rather than short-circuited here. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![0]).toBe(`${DEFAULT_BACKEND}/_proxy_debug`); + + // Nothing the proxy itself wrote may carry the key or its prefix. + const written = JSON.stringify([res.json.mock.calls, res.send.mock.calls, res.write.mock.calls]); + expect(written).not.toContain('test-key-123'); + expect(written).not.toContain('test-key-1'); + expect(written).not.toContain('apiKeyPrefix'); + }); + it('strips the catch-all query param but keeps real query params', async () => { const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue( new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), diff --git a/scripts/langgraph-proxy.ts b/scripts/langgraph-proxy.ts index 4b387079c..9728be537 100644 --- a/scripts/langgraph-proxy.ts +++ b/scripts/langgraph-proxy.ts @@ -152,29 +152,6 @@ export function createProxyHandler(config: ProxyConfig = {}): (req: VercelReques const backendUrl = resolveBackend(req.headers.referer); const targetUrl = `${backendUrl}${apiPath}${cleanSearch}`; - // Debug endpoint — confirms the proxy is wired without hitting the upstream. - if (apiPath === '/_proxy_debug') { - res.status(200).json({ - method: req.method, - url: req.url, - apiPath, - targetUrl, - backendUrl, - referer: req.headers.referer, - query: req.query, - hasApiKey: !!apiKey, - apiKeyPrefix: apiKey.substring(0, 10), - hasDatabaseUrl: !!process.env['DATABASE_URL'], - rateLimitConfigured: !!config.checkRateLimit, - instanceId: (() => { - const g = globalThis as { __instanceId?: string }; - if (!g.__instanceId) g.__instanceId = Math.random().toString(36).slice(2, 10); - return g.__instanceId; - })(), - }); - return; - } - // Body-size cap. Fast-fail before rate-limit + upstream fetch. if (config.maxBodyBytes !== undefined) { const cl = req.headers['content-length'];