Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 268 additions & 0 deletions clients/web/src/test/core/mcp/fetchTracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, it, expect, vi } from "vitest";
import {
createFetchTracker,
redactSensitiveHeaders,
redactBody,
redactUrlQuery,
REDACTED_HEADER_VALUE,
REDACTED_VALUE,
} from "@inspector/core/mcp/fetchTracking.js";
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";

Expand Down Expand Up @@ -358,6 +361,271 @@ describe("createFetchTracker", () => {
expect(responseHeaders["content-type"]).toBe("text/plain");
expect(JSON.stringify(tracked[0])).not.toContain("issued-secret");
});

it("redacts a form-encoded OAuth token request body without touching the live request", async () => {
let outboundInit: RequestInit | undefined;
const baseFetch = vi.fn(
async (_input: RequestInfo | URL, init?: RequestInit) => {
outboundInit = init;
return new Response("ok");
},
);
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
});
const liveBody =
"grant_type=authorization_code&code=secret-auth-code&client_secret=shh&code_verifier=pkce123&client_id=public";
await fetcher("https://auth.example.com/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: liveBody,
});
const recorded = new URLSearchParams(tracked[0]!.requestBody);
expect(recorded.get("code")).toBe(REDACTED_VALUE);
expect(recorded.get("client_secret")).toBe(REDACTED_VALUE);
expect(recorded.get("code_verifier")).toBe(REDACTED_VALUE);
expect(recorded.get("grant_type")).toBe("authorization_code");
expect(recorded.get("client_id")).toBe("public");
expect(JSON.stringify(tracked[0])).not.toContain("secret-auth-code");
expect(JSON.stringify(tracked[0])).not.toContain("shh");
expect(JSON.stringify(tracked[0])).not.toContain("pkce123");
// The live outbound request body is byte-identical.
expect(outboundInit?.body).toBe(liveBody);
});

it("redacts a JSON token response body asynchronously", async () => {
const baseFetch = vi.fn(
async () =>
new Response(
JSON.stringify({
access_token: "live-access",
refresh_token: "live-refresh",
token_type: "Bearer",
expires_in: 3600,
}),
{ headers: { "content-type": "application/json" } },
),
);
const tracked: FetchRequestEntryBase[] = [];
const bodies: Array<{ id: string; body: string }> = [];
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
updateResponseBody: (id, body) => bodies.push({ id, body }),
});
await fetcher("https://auth.example.com/token", { method: "POST" });
await flush();
expect(bodies).toHaveLength(1);
const parsed = JSON.parse(bodies[0]!.body) as Record<string, unknown>;
expect(parsed.access_token).toBe(REDACTED_VALUE);
expect(parsed.refresh_token).toBe(REDACTED_VALUE);
expect(parsed.token_type).toBe("Bearer");
expect(parsed.expires_in).toBe(3600);
expect(bodies[0]!.body).not.toContain("live-access");
expect(bodies[0]!.body).not.toContain("live-refresh");
});

it("redacts sensitive query params in the recorded URL (success + error paths)", async () => {
const baseFetch = vi.fn(async () => new Response("ok"));
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
});
await fetcher(
"https://auth.example.com/callback?state=xyz&code=secret-code&access_token=leaky",
);
expect(tracked[0]!.url).toContain("state=xyz");
expect(tracked[0]!.url).toContain(
`code=${encodeURIComponent(REDACTED_VALUE)}`,
);
expect(tracked[0]!.url).not.toContain("secret-code");
expect(tracked[0]!.url).not.toContain("leaky");

const failing = createFetchTracker(
vi.fn(async () => {
throw new Error("boom");
}) as typeof fetch,
{ trackRequest: (entry) => tracked.push(entry) },
);
await expect(
failing("https://auth.example.com/token?refresh_token=leaked-on-error"),
).rejects.toThrow("boom");
expect(tracked[1]!.url).not.toContain("leaked-on-error");
});

it("leaves non-sensitive bodies and URLs untouched", async () => {
const baseFetch = vi.fn(
async () =>
new Response('{"tools":[]}', {
headers: { "content-type": "application/json" },
}),
);
const tracked: FetchRequestEntryBase[] = [];
const bodies: Array<{ id: string; body: string }> = [];
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
updateResponseBody: (id, body) => bodies.push({ id, body }),
});
await fetcher("https://example.com/mcp?page=2", {
method: "POST",
headers: { "content-type": "application/json" },
body: '{"method":"tools/list"}',
});
await flush();
expect(tracked[0]!.url).toBe("https://example.com/mcp?page=2");
expect(tracked[0]!.requestBody).toBe('{"method":"tools/list"}');
expect(bodies[0]!.body).toBe('{"tools":[]}');
});

it("redacts a nested sensitive key in a JSON request body through the tracker", async () => {
let outboundInit: RequestInit | undefined;
const baseFetch = vi.fn(
async (_input: RequestInfo | URL, init?: RequestInit) => {
outboundInit = init;
return new Response("ok");
},
);
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
});
const liveBody = JSON.stringify({
params: { arguments: { access_token: "sekret", page: 2 } },
});
await fetcher("https://example.com/mcp", {
method: "POST",
headers: { "content-type": "application/json" },
body: liveBody,
});
// Recorded copy has the nested secret masked; non-sensitive siblings kept.
expect(JSON.parse(tracked[0]!.requestBody!)).toEqual({
params: { arguments: { access_token: REDACTED_VALUE, page: 2 } },
});
expect(JSON.stringify(tracked[0])).not.toContain("sekret");
// The live outbound request body is byte-identical (unredacted).
expect(outboundInit?.body).toBe(liveBody);
});

it("does not throw on a malformed body — logs it as-is", async () => {
const baseFetch = vi.fn(async () => new Response("ok"));
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
});
const malformed = "{ this is not: valid json ]]";
await fetcher("https://example.com/x", {
method: "POST",
headers: { "content-type": "application/json" },
body: malformed,
});
expect(tracked[0]!.requestBody).toBe(malformed);
});
});

describe("redactUrlQuery", () => {
it("redacts sensitive params and keeps the path + other params", () => {
expect(
redactUrlQuery("https://x.example/cb?state=ok&code=abc&client_secret=s"),
).toBe(
`https://x.example/cb?state=ok&code=${encodeURIComponent(
REDACTED_VALUE,
)}&client_secret=${encodeURIComponent(REDACTED_VALUE)}`,
);
});

it("returns URLs without a query string unchanged", () => {
expect(redactUrlQuery("https://x.example/path")).toBe(
"https://x.example/path",
);
});

it("returns URLs with only non-sensitive params unchanged", () => {
expect(redactUrlQuery("https://x.example/p?a=1&b=2")).toBe(
"https://x.example/p?a=1&b=2",
);
});

it("matches param names case-insensitively", () => {
const out = redactUrlQuery("https://x.example/cb?CODE=abc");
expect(out).toContain(encodeURIComponent(REDACTED_VALUE));
expect(out).not.toContain("abc");
});

it("preserves a trailing fragment", () => {
expect(redactUrlQuery("https://x.example/p?token=t#section")).toBe(
`https://x.example/p?token=${encodeURIComponent(REDACTED_VALUE)}#section`,
);
});

it("collapses repeated sensitive params to a single redacted value", () => {
expect(redactUrlQuery("https://x.example/p?code=a&code=b")).toBe(
`https://x.example/p?code=${encodeURIComponent(REDACTED_VALUE)}`,
);
});
});

describe("redactBody", () => {
it("returns undefined / empty bodies unchanged", () => {
expect(redactBody(undefined, "application/json")).toBeUndefined();
expect(redactBody("", "application/json")).toBe("");
});

it("redacts form-encoded fields (with a charset in the content-type)", () => {
const out = redactBody(
"client_secret=s&grant_type=client_credentials",
"application/x-www-form-urlencoded; charset=utf-8",
);
const params = new URLSearchParams(out);
expect(params.get("client_secret")).toBe(REDACTED_VALUE);
expect(params.get("grant_type")).toBe("client_credentials");
});

it("leaves a form body with no sensitive fields byte-identical", () => {
const body = "grant_type=client_credentials&scope=read";
expect(redactBody(body, "application/x-www-form-urlencoded")).toBe(body);
});

it("redacts nested JSON objects and arrays", () => {
const out = redactBody(
JSON.stringify({
outer: { password: "p", keep: "v" },
list: [{ token: "t1" }, { token: "t2" }],
}),
"application/json",
);
const parsed = JSON.parse(out!) as {
outer: { password: string; keep: string };
list: Array<{ token: string }>;
};
expect(parsed.outer.password).toBe(REDACTED_VALUE);
expect(parsed.outer.keep).toBe("v");
expect(parsed.list.map((e) => e.token)).toEqual([
REDACTED_VALUE,
REDACTED_VALUE,
]);
});

it("sniffs JSON when the content-type is missing", () => {
const out = redactBody('{"access_token":"x"}', undefined);
expect(JSON.parse(out!)).toEqual({ access_token: REDACTED_VALUE });
});

it("leaves a JSON scalar (no field names) unchanged", () => {
expect(redactBody('"just a string"', "application/json")).toBe(
'"just a string"',
);
});

it("returns a non-JSON, non-form body unchanged", () => {
expect(redactBody("plain text log line", "text/plain")).toBe(
"plain text log line",
);
});

it("does not throw on malformed JSON", () => {
const bad = "{not json";
expect(redactBody(bad, "application/json")).toBe(bad);
});
});

describe("redactSensitiveHeaders", () => {
Expand Down
Loading
Loading