diff --git a/server/src/__tests__/redact-sensitive.test.ts b/server/src/__tests__/redact-sensitive.test.ts new file mode 100644 index 00000000..173fbece --- /dev/null +++ b/server/src/__tests__/redact-sensitive.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { redactSensitive } from "../middleware/redact-sensitive.js"; + +describe("redactSensitive", () => { + it("redacts a plaintext password field on a sign-in body", () => { + const body = { email: "user@example.com", password: "founding6gomez6croaking" }; + + const out = redactSensitive(body) as Record; + + expect(out.email).toBe("user@example.com"); + expect(out.password).toBe("[REDACTED]"); + expect((body as Record).password).toBe("founding6gomez6croaking"); + }); + + it("redacts password key regardless of casing", () => { + expect((redactSensitive({ Password: "x" }) as Record).Password).toBe("[REDACTED]"); + expect((redactSensitive({ PASSWORD: "x" }) as Record).PASSWORD).toBe("[REDACTED]"); + }); + + it("redacts known credential-shaped keys", () => { + const out = redactSensitive({ + currentPassword: "a", + newPassword: "b", + access_token: "c", + refresh_token: "d", + api_key: "e", + authorization: "Bearer f", + }) as Record; + + for (const value of Object.values(out)) { + expect(value).toBe("[REDACTED]"); + } + }); + + it("does not redact a bare `token` field — pagination cursors and CSRF tokens are not credentials", () => { + const out = redactSensitive({ token: "next-page-cursor", limit: 20 }) as Record; + + expect(out.token).toBe("next-page-cursor"); + expect(out.limit).toBe(20); + }); + + it("recurses into nested objects and arrays", () => { + const out = redactSensitive({ + user: { email: "user@example.com", password: "secret-pass" }, + tokens: [{ access_token: "t1" }, { access_token: "t2" }], + }) as Record; + + expect((out.user as Record).email).toBe("user@example.com"); + expect((out.user as Record).password).toBe("[REDACTED]"); + const tokens = out.tokens as Array>; + expect(tokens[0].access_token).toBe("[REDACTED]"); + expect(tokens[1].access_token).toBe("[REDACTED]"); + }); + + it("leaves primitives and non-sensitive keys untouched", () => { + const body = { email: "a@b.c", name: "Alice", count: 7, active: true, missing: null }; + + expect(redactSensitive(body)).toEqual(body); + }); + + it("returns primitives unchanged", () => { + expect(redactSensitive("hello")).toBe("hello"); + expect(redactSensitive(42)).toBe(42); + expect(redactSensitive(null)).toBe(null); + expect(redactSensitive(undefined)).toBe(undefined); + }); + + it("caps recursion depth so cycles do not pin the logger", () => { + const cycle: Record = { name: "root" }; + cycle.self = cycle; + + expect(() => redactSensitive(cycle)).not.toThrow(); + }); + + it("omits deeply-nested arrays at the depth cap instead of leaking null entries to JSON", () => { + // Build an object whose array field is reached at MAX_DEPTH. Recursing + // into the array elements would exceed the cap; without the array-level + // guard, `value.map` would produce `[undefined, ...]` which JSON.stringify + // renders as `[null, ...]`. Object properties at the same cap are + // already absent from the JSON output (JSON.stringify skips undefined + // values on objects), so this test pins the array path to the same + // contract: silently absent, not visible as nulls. + let payload: Record = { values: [1, 2, 3] }; + for (let i = 0; i < 5; i++) payload = { nested: payload }; + + const out = redactSensitive(payload); + + const json = JSON.stringify(out); + expect(json).not.toContain("null"); + expect(json).not.toContain("[1,2,3]"); + }); +}); diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index 7eb5c2b4..e014a34c 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -5,6 +5,7 @@ import { pinoHttp } from "pino-http"; import { readConfigFile } from "../config-file.js"; import { resolveDefaultLogsDir, resolveHomeAwarePath } from "../home-paths.js"; import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js"; +import { redactSensitive } from "./redact-sensitive.js"; function resolveServerLogDir(): string { const envOverride = process.env.PAPERCLIP_LOG_DIR?.trim(); @@ -69,21 +70,21 @@ export const httpLogger = pinoHttp({ if (ctx) { return { errorContext: ctx.error, - reqBody: ctx.reqBody, - reqParams: ctx.reqParams, - reqQuery: ctx.reqQuery, + reqBody: redactSensitive(ctx.reqBody), + reqParams: redactSensitive(ctx.reqParams), + reqQuery: redactSensitive(ctx.reqQuery), }; } const props: Record = {}; const { body, params, query } = req as any; if (body && typeof body === "object" && Object.keys(body).length > 0) { - props.reqBody = body; + props.reqBody = redactSensitive(body); } if (params && typeof params === "object" && Object.keys(params).length > 0) { - props.reqParams = params; + props.reqParams = redactSensitive(params); } if (query && typeof query === "object" && Object.keys(query).length > 0) { - props.reqQuery = query; + props.reqQuery = redactSensitive(query); } if ((req as any).route?.path) { props.routePath = (req as any).route.path; diff --git a/server/src/middleware/redact-sensitive.ts b/server/src/middleware/redact-sensitive.ts new file mode 100644 index 00000000..4b7247a4 --- /dev/null +++ b/server/src/middleware/redact-sensitive.ts @@ -0,0 +1,67 @@ +// 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([ + "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", +]); + +const MAX_DEPTH = 6; +const REDACTED = "[REDACTED]"; + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(key.toLowerCase()); +} + +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 = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (isSensitiveKey(key)) { + out[key] = REDACTED; + continue; + } + out[key] = redactSensitive(entry, depth + 1); + } + return out; +}