diff --git a/client/src/__tests__/proxyFetchEndpoint.test.ts b/client/src/__tests__/proxyFetchEndpoint.test.ts index f23f8d9f..14b6ec8e 100644 --- a/client/src/__tests__/proxyFetchEndpoint.test.ts +++ b/client/src/__tests__/proxyFetchEndpoint.test.ts @@ -3,6 +3,7 @@ * Spawns the server and hits it like any other HTTP client would. */ import { spawn, type ChildProcess } from "child_process"; +import { createServer, type Server } from "http"; import { resolve } from "path"; const TEST_PORT = 16321; @@ -89,4 +90,49 @@ describe("POST /fetch endpoint", () => { expect(body.status).toBe(200); expect(body.body).toBeDefined(); }); + + it("mirrors upstream 404 (non-2xx) when auth token is valid", async () => { + const upstream: Server = createServer((req, res) => { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end('{"error":"not_found"}'); + }); + + await new Promise((resolve, reject) => { + upstream.once("error", reject); + upstream.listen(0, "127.0.0.1", () => resolve()); + }); + + const addr = upstream.address(); + if (!addr || typeof addr === "string") { + upstream.close(); + throw new Error("Expected TCP listen address"); + } + const upstreamUrl = `http://127.0.0.1:${addr.port}/missing`; + + try { + const res = await fetch(`${baseUrl}/fetch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`, + }, + body: JSON.stringify({ + url: upstreamUrl, + init: { method: "GET" }, + }), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as { + ok: boolean; + status: number; + body: string; + }; + expect(body.ok).toBe(false); + expect(body.status).toBe(404); + expect(JSON.parse(body.body)).toEqual({ error: "not_found" }); + } finally { + await new Promise((r) => upstream.close(() => r())); + } + }); }); diff --git a/client/src/lib/__tests__/proxyFetch.test.ts b/client/src/lib/__tests__/proxyFetch.test.ts index c006039f..c67f4384 100644 --- a/client/src/lib/__tests__/proxyFetch.test.ts +++ b/client/src/lib/__tests__/proxyFetch.test.ts @@ -112,15 +112,93 @@ describe("createProxyFetch", () => { expect(body).toBe('{"issuer":"https://example.com"}'); }); - it("throws when proxy returns non-ok response", async () => { + it("returns non-ok Response when upstream status is not 2xx (mirrored by proxy)", async () => { mockFetch.mockResolvedValue({ ok: false, + status: 404, + statusText: "Not Found", + json: () => + Promise.resolve({ + ok: false, + status: 404, + statusText: "Not Found", + headers: { "content-type": "application/json" }, + body: '{"error":"not_found"}', + }), + }); + + const fetchFn = createProxyFetch(configWithProxy); + const response = await fetchFn("https://example.com/.well-known/missing"); + + expect(response.ok).toBe(false); + expect(response.status).toBe(404); + expect(response.statusText).toBe("Not Found"); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.text()).toBe('{"error":"not_found"}'); + }); + + it("returns non-ok Response when upstream returns 400 with token-endpoint error JSON (RFC 6749)", async () => { + const tokenErrorBody = + '{"error":"invalid_grant","error_description":"code expired"}'; + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + json: () => + Promise.resolve({ + ok: false, + status: 400, + statusText: "Bad Request", + headers: { "content-type": "application/json" }, + body: tokenErrorBody, + }), + }); + + const fetchFn = createProxyFetch(configWithProxy); + const response = await fetchFn("https://example.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "grant_type=authorization_code&code=x", + }); + + expect(response.ok).toBe(false); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "invalid_grant", + error_description: "code expired", + }); + }); + + it("throws when proxy POST returns JSON error envelope (e.g. 401 invalid session)", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, statusText: "Unauthorized", + json: () => + Promise.resolve({ + error: "Unauthorized", + message: + "Authentication required. Use the session token shown in the console when starting the server.", + }), }); const fetchFn = createProxyFetch(configWithProxy); await expect(fetchFn("https://example.com/")).rejects.toThrow( - "Proxy fetch failed: Unauthorized", + "Authentication required. Use the session token shown in the console when starting the server.", + ); + }); + + it("throws when proxy response is not valid JSON", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 502, + statusText: "Bad Gateway", + json: () => Promise.reject(new SyntaxError("Unexpected token")), + }); + + const fetchFn = createProxyFetch(configWithProxy); + await expect(fetchFn("https://example.com/")).rejects.toThrow( + "Proxy fetch failed: 502 Bad Gateway", ); }); diff --git a/client/src/lib/proxyFetch.ts b/client/src/lib/proxyFetch.ts index 069ebcb0..f7017d05 100644 --- a/client/src/lib/proxyFetch.ts +++ b/client/src/lib/proxyFetch.ts @@ -9,6 +9,77 @@ interface ProxyFetchResponse { body: string; } +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * JSON body from POST /fetch when the proxy itself failed (auth, TLS, etc.), + * not a mirrored upstream HTTP response. + */ +function messageFromProxyInfrastructureError(data: unknown): string | null { + if (!isJsonObject(data)) { + return null; + } + const rec = data; + if (!("error" in rec) || "status" in rec) { + return null; + } + if (typeof rec.message === "string") { + return rec.message; + } + if (typeof rec.error === "string") { + return rec.error; + } + return "Proxy fetch failed"; +} + +/** + * Validates the JSON shape the proxy returns when it successfully forwarded + * a request and is mirroring the upstream response. + */ +function parseMirroredUpstreamJson(data: unknown): ProxyFetchResponse | null { + if (!isJsonObject(data)) { + return null; + } + const rec = data; + + if (typeof rec.status !== "number") { + return null; + } + if (typeof rec.body !== "string") { + return null; + } + if (typeof rec.statusText !== "string") { + return null; + } + if (typeof rec.ok !== "boolean") { + return null; + } + if (rec.headers === null || typeof rec.headers !== "object") { + return null; + } + if (Array.isArray(rec.headers)) { + return null; + } + + const headers: Record = {}; + for (const [key, val] of Object.entries(rec.headers)) { + if (typeof val !== "string") { + return null; + } + headers[key] = val; + } + + return { + ok: rec.ok, + status: rec.status, + statusText: rec.statusText, + headers, + body: rec.body, + }; +} + /** * Creates a fetch function that routes requests through the proxy server * to avoid CORS restrictions on OAuth discovery and token endpoints. @@ -54,16 +125,29 @@ export function createProxyFetch(config: InspectorConfig): typeof fetch { }), }); - if (!proxyResponse.ok) { - throw new Error(`Proxy fetch failed: ${proxyResponse.statusText}`); + let data: unknown; + try { + data = await proxyResponse.json(); + } catch { + throw new Error( + `Proxy fetch failed: ${proxyResponse.status} ${proxyResponse.statusText}`, + ); } - const data: ProxyFetchResponse = await proxyResponse.json(); + const infraMessage = messageFromProxyInfrastructureError(data); + if (infraMessage !== null) { + throw new Error(infraMessage); + } - return new Response(data.body, { - status: data.status, - statusText: data.statusText, - headers: new Headers(data.headers), + const mirrored = parseMirroredUpstreamJson(data); + if (mirrored === null) { + throw new Error("Proxy fetch failed: unexpected response shape"); + } + + return new Response(mirrored.body, { + status: mirrored.status, + statusText: mirrored.statusText, + headers: new Headers(mirrored.headers), }); }; }