paperclip/server/src/middleware/redact-sensitive.ts

129 lines
4.0 KiB
TypeScript

// Redaction for HTTP log payloads.
//
// `customProps` in logger.ts copies `req.body` / `req.params` / `req.query`
// verbatim into the 4xx/5xx log lines so operators can diagnose. That means
// Better Auth's `POST /api/auth/sign-in/email` body (which has the user's
// plaintext password) and similar payloads (sign-up, reset-password, API
// keys via Authorization header equivalents) end up on disk.
//
// This walker returns a shallow copy of the input with values for sensitive
// keys replaced with the literal string "[REDACTED]". Recurses into nested
// objects/arrays. Caps depth so a hostile or accidental cycle can't pin
// the logger.
const SENSITIVE_KEYS = new Set<string>([
"password",
"currentpassword",
"newpassword",
"passwordconfirmation",
"password_confirmation",
"passwordconfirm",
"password_confirm",
"confirmpassword",
"confirm_password",
"secret",
"client_secret",
"clientsecret",
"access_token",
"accesstoken",
"refresh_token",
"refreshtoken",
"id_token",
"idtoken",
"api_key",
"apikey",
"authorization",
"auth_token",
"authtoken",
"session_token",
"sessiontoken",
"private_key",
"privatekey",
"paperclip_capability",
// The Claude setup-token login fields. `browserCode` carries the one-time
// sign-in code and `authorization_code` carries the OAuth code; neither may
// reach a log line.
"browsercode",
"authorization_code",
"authorizationcode",
// The workspace login handoff ticket (PAP-17572). It is a signed bearer
// credential carried as a query parameter, so it must never reach a log line
// even though the exchange itself answers 302.
"ticket",
// Not secrets Paperclip holds, but attacker-authored prose: an OAuth provider
// controls `error_description` / `error_uri` on the callback query string, and
// `customProps` copies the whole query into 4xx log lines. Paperclip maps the
// `error` code to its own copy instead of reflecting these, so they have no
// debugging value here either (PAP-17108).
"error_description",
"errordescription",
"error_uri",
"erroruri",
]);
const MAX_DEPTH = 6;
const REDACTED = "[REDACTED]";
const URLISH_KEYS = new Set<string>([
"href",
"locator",
"source",
"source_locator",
"sourcelocator",
"source_url",
"sourceurl",
"uri",
"url",
// The Claude setup-token login URL. A structured `loginUrl` that reaches a log
// sink keeps its origin and path only; the OAuth query, fragment, and any
// credentials are stripped (SR-5 backstop).
"loginurl",
"login_url",
]);
function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEYS.has(key.toLowerCase());
}
function isUrlishKey(key: string): boolean {
return URLISH_KEYS.has(key.toLowerCase());
}
export function stripSecretBearingUrlParts(value: string): string {
const suffixStart = value.search(/[?#]/);
const withoutQueryOrFragment = suffixStart === -1 ? value : value.slice(0, suffixStart);
try {
const url = new URL(withoutQueryOrFragment);
if (!url.username && !url.password && suffixStart === -1) return value;
url.username = "";
url.password = "";
return url.toString();
} catch {
// Request URLs are normally origin-form paths rather than absolute URLs.
// They still need the same query/fragment policy as URL-valued payloads.
return withoutQueryOrFragment;
}
}
export function redactSensitive(value: unknown, depth = 0): unknown {
if (depth > MAX_DEPTH) return undefined;
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) {
if (depth + 1 > MAX_DEPTH) return undefined;
return value.map((entry) => redactSensitive(entry, depth + 1));
}
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (isSensitiveKey(key)) {
out[key] = REDACTED;
continue;
}
if (typeof entry === "string" && isUrlishKey(key)) {
out[key] = stripSecretBearingUrlParts(entry);
continue;
}
out[key] = redactSensitive(entry, depth + 1);
}
return out;
}