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
1 changed files with 398 additions and 5 deletions
|
|
@ -20,6 +20,35 @@ const SENSITIVE_HEADERS: ReadonlySet<string> = new Set([
|
|||
/** Placeholder substituted for sensitive header values in recorded entries. */
|
||||
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
|
||||
* replaced by {@link REDACTED_HEADER_VALUE}. Comparison is case-insensitive
|
||||
|
|
@ -38,6 +67,116 @@ export function redactSensitiveHeaders(
|
|||
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
|
||||
* whose body cannot be cloned + read to completion. The streamable HTTP
|
||||
|
|
@ -110,6 +249,11 @@ export function createFetchTracker(
|
|||
});
|
||||
}
|
||||
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)
|
||||
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
|
||||
let response: Response;
|
||||
let error: string | undefined;
|
||||
|
|
@ -148,9 +296,9 @@ export function createFetchTracker(
|
|||
id,
|
||||
timestamp,
|
||||
method,
|
||||
url,
|
||||
url: redactedUrl,
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
requestBody: redactedRequestBody,
|
||||
error,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
|
|
@ -193,9 +341,9 @@ export function createFetchTracker(
|
|||
id,
|
||||
timestamp,
|
||||
method,
|
||||
url,
|
||||
url: redactedUrl,
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
requestBody: redactedRequestBody,
|
||||
responseStatus,
|
||||
responseStatusText,
|
||||
responseHeaders,
|
||||
|
|
@ -211,12 +359,18 @@ export function createFetchTracker(
|
|||
// via `updateResponseBody`. Skipped for long-lived streams (GET +
|
||||
// SSE / ndjson) because `.text()` would never resolve on those.
|
||||
if (!isLongLivedStream && response.body && !response.bodyUsed) {
|
||||
const responseContentType = response.headers.get("content-type");
|
||||
try {
|
||||
const cloned = response.clone();
|
||||
cloned
|
||||
.text()
|
||||
.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(() => {
|
||||
// Stream errored after clone — leave the body undefined.
|
||||
|
|
|
|||
Loading…
Reference in a new issue