feat(core): redact sensitive body + URL-query fields in fetch log (closes #1593)
Extends the header redaction from #1585. Before a fetch entry reaches any sink, mask known-sensitive credentials in the request/response bodies and the URL query string, in addition to headers: - redactBody(): form-urlencoded + JSON bodies, recursive over nested objects/arrays, best-effort (never throws), values-only. - redactUrlQuery(): masks sensitive query params, preserves path, non-sensitive params, and any fragment. - Sensitive fields: client_secret, code, refresh_token, access_token, id_token, code_verifier, client_assertion, assertion, password, token (case-insensitive), reusing the [REDACTED] sentinel. Applied to the request success + error entries and the async response-body callback. Live outbound requests are byte-identical — only the logged copy is redacted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
parent
b2ff34a901
commit
524bf2d543
2 changed files with 398 additions and 5 deletions
|
|
@ -2,7 +2,10 @@ import { describe, it, expect, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
createFetchTracker,
|
createFetchTracker,
|
||||||
redactSensitiveHeaders,
|
redactSensitiveHeaders,
|
||||||
|
redactBody,
|
||||||
|
redactUrlQuery,
|
||||||
REDACTED_HEADER_VALUE,
|
REDACTED_HEADER_VALUE,
|
||||||
|
REDACTED_VALUE,
|
||||||
} from "@inspector/core/mcp/fetchTracking.js";
|
} from "@inspector/core/mcp/fetchTracking.js";
|
||||||
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
|
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
|
||||||
|
|
||||||
|
|
@ -358,6 +361,242 @@ describe("createFetchTracker", () => {
|
||||||
expect(responseHeaders["content-type"]).toBe("text/plain");
|
expect(responseHeaders["content-type"]).toBe("text/plain");
|
||||||
expect(JSON.stringify(tracked[0])).not.toContain("issued-secret");
|
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("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", () => {
|
describe("redactSensitiveHeaders", () => {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,35 @@ const SENSITIVE_HEADERS: ReadonlySet<string> = new Set([
|
||||||
/** Placeholder substituted for sensitive header values in recorded entries. */
|
/** Placeholder substituted for sensitive header values in recorded entries. */
|
||||||
export const REDACTED_HEADER_VALUE = "[REDACTED]";
|
export const REDACTED_HEADER_VALUE = "[REDACTED]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field / query-parameter names whose values are masked in a recorded fetch
|
||||||
|
* entry's request body, response body, and URL query string. These are the
|
||||||
|
* credentials that ride in OAuth token exchanges (and similar flows): the
|
||||||
|
* header slice masks `Authorization`, but the same secrets show up verbatim in
|
||||||
|
* the form/JSON body (`client_secret`, `code`, `refresh_token`, …) and are
|
||||||
|
* sometimes carried as URL query params. Matching is case-insensitive.
|
||||||
|
*/
|
||||||
|
const SENSITIVE_BODY_FIELDS: ReadonlySet<string> = new Set([
|
||||||
|
"client_secret",
|
||||||
|
"code",
|
||||||
|
"refresh_token",
|
||||||
|
"access_token",
|
||||||
|
"id_token",
|
||||||
|
"code_verifier",
|
||||||
|
"client_assertion",
|
||||||
|
"assertion",
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Placeholder substituted for sensitive body / URL values in recorded entries. */
|
||||||
|
export const REDACTED_VALUE = "[REDACTED]";
|
||||||
|
|
||||||
|
/** Whether `name` (any casing) is a known-sensitive field / query-param name. */
|
||||||
|
function isSensitiveField(name: string): boolean {
|
||||||
|
return SENSITIVE_BODY_FIELDS.has(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a copy of `headers` with every {@link SENSITIVE_HEADERS} value
|
* Returns a copy of `headers` with every {@link SENSITIVE_HEADERS} value
|
||||||
* replaced by {@link REDACTED_HEADER_VALUE}. Comparison is case-insensitive
|
* replaced by {@link REDACTED_HEADER_VALUE}. Comparison is case-insensitive
|
||||||
|
|
@ -38,6 +67,116 @@ export function redactSensitiveHeaders(
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `url` with every {@link SENSITIVE_BODY_FIELDS} query-parameter value
|
||||||
|
* replaced by {@link REDACTED_VALUE}. The path and non-sensitive params stay
|
||||||
|
* readable. Best-effort: if the URL (or its query string) can't be parsed the
|
||||||
|
* original string is returned unchanged. Only the recorded copy is redacted —
|
||||||
|
* the live request still uses the original `input`/`init`.
|
||||||
|
*/
|
||||||
|
export function redactUrlQuery(url: string): string {
|
||||||
|
const queryStart = url.indexOf("?");
|
||||||
|
if (queryStart === -1) return url;
|
||||||
|
|
||||||
|
const base = url.slice(0, queryStart);
|
||||||
|
const afterQuery = url.slice(queryStart + 1);
|
||||||
|
// Preserve a trailing fragment (#…) untouched — it never carries query params.
|
||||||
|
const hashStart = afterQuery.indexOf("#");
|
||||||
|
const query = hashStart === -1 ? afterQuery : afterQuery.slice(0, hashStart);
|
||||||
|
const fragment = hashStart === -1 ? "" : afterQuery.slice(hashStart);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
let changed = false;
|
||||||
|
for (const key of new Set(params.keys())) {
|
||||||
|
if (isSensitiveField(key)) {
|
||||||
|
changed = true;
|
||||||
|
// Collapse repeated occurrences to a single redacted value.
|
||||||
|
params.set(key, REDACTED_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) return url;
|
||||||
|
return `${base}?${params.toString()}${fragment}`;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively redact sensitive keys in a parsed JSON value (in place). */
|
||||||
|
function redactJsonValue(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(redactJsonValue);
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
out[key] = isSensitiveField(key) ? REDACTED_VALUE : redactJsonValue(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `body` with every {@link SENSITIVE_BODY_FIELDS} value masked, for
|
||||||
|
* `application/x-www-form-urlencoded` and JSON payloads. The surrounding shape
|
||||||
|
* (field order, non-sensitive fields, JSON structure) is preserved — only the
|
||||||
|
* values change. Best-effort and never throws: an empty, non-string, or
|
||||||
|
* unparseable body is returned unchanged. Only the recorded copy is redacted;
|
||||||
|
* the live request body is never touched.
|
||||||
|
*/
|
||||||
|
export function redactBody(
|
||||||
|
body: string | undefined,
|
||||||
|
contentType: string | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!body) return body;
|
||||||
|
|
||||||
|
const type = (contentType ?? "").toLowerCase();
|
||||||
|
|
||||||
|
// Form-encoded bodies (the OAuth token endpoint's request format).
|
||||||
|
if (type.includes("application/x-www-form-urlencoded")) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(body);
|
||||||
|
let changed = false;
|
||||||
|
for (const key of new Set(params.keys())) {
|
||||||
|
if (isSensitiveField(key)) {
|
||||||
|
changed = true;
|
||||||
|
params.set(key, REDACTED_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? params.toString() : body;
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON bodies — either explicitly typed, or (when the content-type is
|
||||||
|
// missing/other) any string that parses as a JSON object/array. A bare
|
||||||
|
// JSON scalar has no field names, so it can't carry a sensitive key.
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(body);
|
||||||
|
if (parsed !== null && typeof parsed === "object") {
|
||||||
|
return JSON.stringify(redactJsonValue(parsed));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON — fall through and leave as-is.
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive lookup of a header value from a plain header record. */
|
||||||
|
function findHeader(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
name: string,
|
||||||
|
): string | undefined {
|
||||||
|
const target = name.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === target) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a response represents an unbounded (long-lived) HTTP stream
|
* Whether a response represents an unbounded (long-lived) HTTP stream
|
||||||
* whose body cannot be cloned + read to completion. The streamable HTTP
|
* whose body cannot be cloned + read to completion. The streamable HTTP
|
||||||
|
|
@ -110,6 +249,11 @@ export function createFetchTracker(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const requestHeaders = redactSensitiveHeaders(rawRequestHeaders);
|
const requestHeaders = redactSensitiveHeaders(rawRequestHeaders);
|
||||||
|
const requestContentType = findHeader(rawRequestHeaders, "content-type");
|
||||||
|
|
||||||
|
// Redact sensitive query params in the recorded URL (live `input` is
|
||||||
|
// untouched — only this logged copy is masked).
|
||||||
|
const redactedUrl = redactUrlQuery(url);
|
||||||
|
|
||||||
// Extract body (if present and readable)
|
// Extract body (if present and readable)
|
||||||
let requestBody: string | undefined;
|
let requestBody: string | undefined;
|
||||||
|
|
@ -136,6 +280,10 @@ export function createFetchTracker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redact sensitive fields in the recorded request body. The live request
|
||||||
|
// body (`init.body` / `input`) is never touched — only this logged string.
|
||||||
|
const redactedRequestBody = redactBody(requestBody, requestContentType);
|
||||||
|
|
||||||
// Make the actual fetch request
|
// Make the actual fetch request
|
||||||
let response: Response;
|
let response: Response;
|
||||||
let error: string | undefined;
|
let error: string | undefined;
|
||||||
|
|
@ -148,9 +296,9 @@ export function createFetchTracker(
|
||||||
id,
|
id,
|
||||||
timestamp,
|
timestamp,
|
||||||
method,
|
method,
|
||||||
url,
|
url: redactedUrl,
|
||||||
requestHeaders,
|
requestHeaders,
|
||||||
requestBody,
|
requestBody: redactedRequestBody,
|
||||||
error,
|
error,
|
||||||
duration: Date.now() - startTime,
|
duration: Date.now() - startTime,
|
||||||
};
|
};
|
||||||
|
|
@ -193,9 +341,9 @@ export function createFetchTracker(
|
||||||
id,
|
id,
|
||||||
timestamp,
|
timestamp,
|
||||||
method,
|
method,
|
||||||
url,
|
url: redactedUrl,
|
||||||
requestHeaders,
|
requestHeaders,
|
||||||
requestBody,
|
requestBody: redactedRequestBody,
|
||||||
responseStatus,
|
responseStatus,
|
||||||
responseStatusText,
|
responseStatusText,
|
||||||
responseHeaders,
|
responseHeaders,
|
||||||
|
|
@ -211,12 +359,18 @@ export function createFetchTracker(
|
||||||
// via `updateResponseBody`. Skipped for long-lived streams (GET +
|
// via `updateResponseBody`. Skipped for long-lived streams (GET +
|
||||||
// SSE / ndjson) because `.text()` would never resolve on those.
|
// SSE / ndjson) because `.text()` would never resolve on those.
|
||||||
if (!isLongLivedStream && response.body && !response.bodyUsed) {
|
if (!isLongLivedStream && response.body && !response.bodyUsed) {
|
||||||
|
const responseContentType = response.headers.get("content-type");
|
||||||
try {
|
try {
|
||||||
const cloned = response.clone();
|
const cloned = response.clone();
|
||||||
cloned
|
cloned
|
||||||
.text()
|
.text()
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
callbacks.updateResponseBody?.(id, body);
|
// Mask token-endpoint secrets (access_token, refresh_token, …)
|
||||||
|
// before the body reaches any sink.
|
||||||
|
callbacks.updateResponseBody?.(
|
||||||
|
id,
|
||||||
|
redactBody(body, responseContentType) ?? body,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Stream errored after clone — leave the body undefined.
|
// Stream errored after clone — leave the body undefined.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue