129 lines
4.0 KiB
TypeScript
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;
|
|
}
|