Files
PaperClipAI/server/src/middleware/redact-sensitive.ts
T
bb7978327e fix(logger): redact passwords and tokens from HTTP error log lines (#8013)
Resubmits #5820 by @echokos. The original PR's head fork could not
accept maintainer edits (organization-owned fork without cross-org
maintainer-edit access), so we've resubmitted the commits here with
original authorship preserved. Thanks to @echokos for the contribution.

---

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies; the
server is an Express app with structured HTTP logging via pino-http.
> - The middleware in `server/src/middleware/logger.ts` defines a
`customProps` hook that attaches request context (`req.body` /
`req.params` / `req.query`) to every 4xx/5xx log entry so operators can
diagnose failed requests.
> - That hook copies the body verbatim. Better Auth's `POST
/api/auth/sign-in/email` carries an `{ email, password }` body — on a
wrong-password attempt the request lands in the 4xx branch and the
plaintext password is written to `~/.paperclip/logs/server.log`.
> - Two existing issues raise this (#3072 plaintext-password leak, #4759
similar concerns) and neither has a fix.
> - Same exposure surface applies to sign-up, reset-password, API key
creation, and any endpoint that accepts a credential in the body and can
return 4xx.
> - This pull request introduces a small `redactSensitive` walker that
returns a shallow copy of the input with values for known
credential-shaped keys replaced with `[REDACTED]`, and applies it at
every body/params/query log site in `customProps`.
> - The benefit is that operators can keep diagnostic logging on without
their disk silently accumulating user passwords and bearer tokens.

## What Changed

- `server/src/middleware/redact-sensitive.ts` (new): depth-capped,
case-insensitive walker. Sensitive keys covered: `password`,
`currentPassword`, `newPassword`, `passwordConfirmation`,
`passwordConfirm`, `confirmPassword` (+ snake_case variants), `secret`,
`client_secret`, `access_token`, `refresh_token`, `id_token`,
`auth_token`, `session_token`, `api_key`, `authorization`,
`private_key`. Bare `token` deliberately not in the list — pagination
cursors and CSRF tokens are not credentials (per Greptile review).
- `server/src/middleware/logger.ts`: wraps the six log sites in
`customProps` (3 ctx-path + 3 fallback-path) with `redactSensitive`.
- `server/src/__tests__/redact-sensitive.test.ts` (new): covers
plaintext password, case-insensitive matching, multiple credential keys,
nested objects/arrays, bare `token` left untouched, primitives
untouched, cycle safety.
- Depth-cap returns `undefined` (field absent from log line) rather than
a sentinel string, per Greptile review.

## Verification

- `pnpm --filter @paperclipai/server test redact-sensitive` should run
the new test file green.
- Manual: tail `~/.paperclip/logs/server.log`, hit `POST
/api/auth/sign-in/email` with a deliberately wrong password, confirm the
logged `reqBody.password` reads `[REDACTED]` (not the plaintext) and the
surrounding fields still appear for diagnosis.

## Risks

Low. The walker only rewrites values at the log-emit boundary — the
actual `req.body` object handed to downstream handlers is unchanged
because `redactSensitive` returns a new object. Standard log fields
(email, route path, status code) remain visible. The sensitive-key list
is conservative enough that the only risk is over-redacting a
non-credential field that happens to share a name with a known
credential; the bare `token` carve-out in this revision addresses the
most obvious such case.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), Anthropic, extended thinking
mode, working through Claude Code CLI.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [ ] I have run tests locally and they pass (no `node_modules` in my
disposable PR-prep checkout; CI vitest will exercise)
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (N/A — server-only)
- [ ] I have updated relevant documentation to reflect my changes (no
doc surface affected)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Closes #3072. Refs #4759.

---

## Original Context

`customProps` in the HTTP logger copies `req.body` / `req.params` /
`req.query` verbatim into 4xx/5xx log entries. Better Auth's
wrong-password flow therefore writes:

```
{"reqBody":{"email":"…","password":"founding6gomez6croaking"},"msg":"POST /api/auth/sign-in/email 401"}
```

…to disk. This PR rewrites credential-shaped values to `[REDACTED]` at
that boundary.

---

## Cross-references and status (maintainer)

Closes #5820
Closes #3095
Closes #4760
Closes #4886

---------

Co-authored-by: Aurora <aurora@majorimpact.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-11 17:09:32 -07:00

68 lines
1.9 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",
]);
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<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (isSensitiveKey(key)) {
out[key] = REDACTED;
continue;
}
out[key] = redactSensitive(entry, depth + 1);
}
return out;
}