fix(logging): redact credentials and narrow the notification queue payload - #747
Conversation
…yload Session cookies, bearer tokens and the Bungie OAuth authorization code were reaching the logs on ordinary requests, and the single-recipient notification path published whole user documents into Redis. Pino's standard request serializer records `cookie`, `authorization` and the request target verbatim, and the response serializer records `set-cookie` - so `GET /users/signIn/Bungie?code=...&state=...` put the authorization code on the request line before the route had exchanged it, and every sign-in logged the session cookie twice. `helpers/log.js` configured no redaction to catch any of it. Add `helpers/redact.js`: one list of credential-bearing keys compiled into Pino's `redact` paths, plus `redactUrl`/`redactQuery` for the secrets that sit inside a URL rather than at an object path. Redaction goes on the root logger because that is the only hook that runs after the serializers and covers child bindings - a `formatters.log` hook sees neither, so it could not reach the serializer output or the per-request child from `contextMiddleware`. `code` is not redacted by key. It names a verification code under `membership.tokens` and a diagnostic everywhere else, so it is censored by position: under `tokens`, and as a query parameter. On the queue, `Publisher.sendNotification` now writes only the identifiers `NotificationController.#send` reads. Narrowing in the publisher rather than at the call sites protects the thing that needs it - a BullMQ job outlives the request that created it and is read back by a worker - and the broadcast path was already passing a projection. The subscriber stops spreading the payload into its log event, which also covers jobs enqueued before this change; those still process, since `#send` destructures the same three fields. Also stop logging the whole Twilio message document on delete, which carried `Body` - the verification code itself on a verification SMS. Verified end to end rather than by unit test alone: seven sentinels driven through the real Express, pino-http and log.js stack, none of which survive into stdout. That capture is what found `req.query`, which the standard serializer emits alongside the URL and which a hand-built test request does not have - the OAuth code was still leaking after the URL had been censored. Refs #711 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
Review of #747 found a regression the new tests could not catch, and asked for the redaction overhead to be measured rather than assumed. `pino-http` defaults to `wrapSerializers`, which passes a custom request serializer the output of `stdSerializers.req` rather than the request. The wrapper short-circuits when the serializer *is* `stdSerializers.req`, so the previous config was unaffected - but the new one re-serialized an object with no `socket`, dropping `remoteAddress` and `remotePort` from every request log. Edit what is handed over instead, which also keeps the prototype whose non-enumerable `raw` getter other options read through. `query` is replaced rather than written through, because the standard serializer assigns Express's own query object by reference and a censor written in place would reach the route handler. The spec now installs the serializer through `wrapRequestSerializer`, the way `pino-http` does, and asserts the field set matches the standard serializer's - the hand-built request it used before is what hid this. On cost: a wildcarded path is about 390ns per log line, while a root key or a spelled-out path compiles to a direct read. Split the one flat list into three tiers - every key at the root, a nestable subset one level down, and the deep shapes named outright - which takes a representative request-completion event from roughly 21.5µs to 4µs per line and widens coverage at the same time: `tokens` is now censored whole, so a join request's email blob is covered too, and both casings of the header names are listed since Pino matches them case-sensitively. Also from review: `[Redacted]` left a query parameter spelled `%5BRedacted%5D` once `URLSearchParams` had encoded it, so the censor is now `REDACTED`, which has no reserved characters. The two bounds on the safety net - nothing below the second level unless named, and nothing inside an array - are stated in the `redact` JSDoc rather than left to be discovered. Refs #711 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
|
Thanks — #1 is a real regression and you diagnosed it exactly right. Fixed in 3a7a3b8 along with #2 and the nits. 1.
|
| config | ns/line | paths |
|---|---|---|
| no redact | ~650 | 0 |
| depth 1 only | ~640 | 17 |
| depth 2 | ~8,700 | 34 |
| depth 3 (as reviewed) | ~21,500 | 51 |
Root keys are free; each wildcarded path costs ~390ns. So I took your suggestion and split the flat list into three tiers:
- every key at the root — free
- a nestable subset wildcarded one level (the token family only; header names never appear one level down)
- the deep shapes named outright —
req.headers.*,res.headers["set-cookie"],user.bungie.*,user.membership.tokens
~21,500ns → ~4,200ns, and coverage got wider, not narrower: tokens is now censored as a whole object, which also covers a join request's tokens.emailAddress — an email verification blob the original by-member paths missed entirely.
3. Retained jobs — you're right, and the PR was wrong
"Nothing needs draining" was true for correctness and wrong about exposure; the "Left open" section did not cover this, so nothing to ignore. removeOnComplete 24h and removeOnFail 7d, with refresh_tokens in the payloads. The Migration section now carries the one-time queue.clean(0, 0, 'completed' | 'failed') as a deploy step.
Not baking it into startup: it is a one-time action, and running it every boot would permanently discard job history. Waiting and delayed jobs are left alone deliberately — they are pending sends, they process correctly in the old format, and they age out.
Nits
- Encoded censor — fixed,
REDACTEDthroughout. The new spec caught this on its own, since it asserts the URL containscensor. - Case sensitivity — good catch, and verified.
Authorization/Cookieadded to the root tier and toknownPathsunderreq.headers. - Arrays — kept the ceiling, documented the gap. Now stated in the
redactJSDoc, specifically that the broadcast path works on user arrays, sousers[0].bungieis out of reach at any depth setting. - Phone numbers — agreed, out of scope here. They are the delivery audit trail in the consent and claim-check paths, so censoring them would blind it. Happy to open a follow-up tied to the retention review.
pnpm test 932 passed / 1 skipped, lint and typecheck clean, and the end-to-end capture re-run: seven sentinels in, none surviving, remoteAddress back.
(Your user.routes.spec failure is environmental as you suspected — Temporal is a global on Node 26, which engines pins.)
🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
Follow-up review found that the tiered paths had narrowed one shape more than
intended. `user.bungie.*` and `user.membership.tokens` spelled out a key the
call site chooses, so the same document logged as `{ bungieUser: doc }` kept
its access token, refresh token and verification pair in the clear. The first
revision's wildcards had covered this; the retier dropped it.
Fixed by naming the containers rather than the key they hang off. `bungie`
joins `tokens` as a key censored whole, in both the root and nestable tiers,
and `user.membership.tokens` becomes `*.membership.tokens`. Since that replaces
three spelled-out entries with two wildcards, the path count moves 36 to 38 and
the document is caught whatever it was called. Removing either path fails the
new parameterised spec.
Censoring the container also covers a member added later, which is why `bungie`
is redacted whole rather than by token field.
`X-API-Key` is listed alongside the lower-case spelling. The suggestion came
with the reasoning that the Bungie client might log it that way, which does not
hold - all eight call sites in `destiny/` and `destiny2/` write lower-case
`'x-api-key'` - but it is Bungie's documented spelling and a root-tier key
costs nothing, so the net may as well hold. The JSDoc now states the rule the
list actually follows instead of implying this codebase writes that casing.
Also correcting a measurement published in the previous commit message: the
~4,200ns figure came from a smaller payload in the variant comparison, not from
the request-completion event it was attributed to. Measured consistently, the
first revision costs ~21,300ns on that event and what ships here costs
~7,345ns.
Refs #711
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
|
Both worth addressing, though one of them for a different reason than given. Fixed in efbe502. 1. User documents outside
|
| config | paths | http completion | worker event |
|---|---|---|---|
| no redact | 0 | ~630 ns | ~485 ns |
| first revision (as reviewed) | 51 | ~21,300 ns | ~20,500 ns |
| shipped | 38 | ~7,345 ns | ~5,431 ns |
~21.3µs → ~7.3µs, about 2.9x rather than the 5x I implied. The conclusion holds and the tiering still earns its place, but the figure was wrong and it was in the PR body too, now corrected.
pnpm test 935 passed / 1 skipped, lint and typecheck clean.
🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
Refs #711. Deliberately not
Closes— the last acceptance criterion is an operational review, not a code change. See Left open.Two of the issue's three evidence bullets were already fixed by #737:
signUp()no longer merges the request body, and the sign-up route already logged allowlisted fields. The rest was live, and the audit turned up more than the issue listed.What was leaking
stdSerializers.reqcookie(session cookie),authorization,x-api-keystdSerializers.resset-cookie— the session cookie, on every sign-inreq.url/req.queryGET /users/signIn/Bungie?code=<Bungie OAuth code>&state=…, on the request linehelpers/log.jsredactconfigured at allnotification.controller.jsbungie.access_token,refresh_token,membership.tokens.code/.blobhelpers/subscriber.jsuser.service.jsBody— the verification code itself on a verification SMSApproach
helpers/redact.jsholds one list of credential-bearing keys, compiled into Pinoredactpaths at three depths, plusredactUrl/redactQueryfor secrets that live inside a URL string rather than at an object path.Redaction goes on the root logger, and the alternative was checked rather than assumed: a
formatters.loghook runs before the serializers and never sees child bindings, so it could reach neitherstdSerializers.reqoutput norcontextMiddleware's per-request child.redactruns after serializers and covers children.codeis not redacted by key — it names a verification code undermembership.tokensand a diagnostic (err.code,DestinyError.code) everywhere else. It is censored by position instead: undertokens, and as a query parameter. There is a test assertingerr.codesurvives.On the queue,
Publisher.sendNotificationnow writes only the three identifiers#sendreads. Narrowing in the publisher rather than at the call sites protects the thing that actually needs it — a job outlives the request that created it and is read back by a worker — and the broadcast path was already passing a projection.Review round 2
@-reviewer found a regression the tests could not catch. Confirmed against
mainand fixed in 3a7a3b8:remoteAddress/remotePortwere being dropped.pino-httpdefaults towrapSerializers, whose wrapper short-circuits only when the serializer isstdSerializers.req— so the old config was fine and the new one re-serialized an already-serialized object with nosocket. The serializer now edits what it is handed, and the spec installs it throughwrapRequestSerializerthe waypino-httpdoes.REDACTED(no reserved characters), header casing variants added, array limitation documented.Review round 3 (efbe502)
user. Reproduced:{ bungieUser: doc }leakedaccess_token,refresh_tokenand the verification pair. Fixed by naming the containers —bungiejoinstokensas a key censored whole, anduser.membership.tokensbecomes*.membership.tokens— so the document is caught whatever the call site called it. Replaces three spelled-out paths with two wildcards, so the count moves 36 → 38.X-API-Keylisted alongside the lower-case spelling.Cost, measured consistently
60k iterations, sink destination, Node 26:
Correcting myself: the "~4µs" I quoted in the round-2 comment was measured on a smaller payload from the variant comparison, not on the request-completion event I attributed it to. The honest reduction on that event is ~21.3µs → ~7.3µs, about 2.9x rather than 5x.
What this costs a request
To be unambiguous, since the table above is easy to misread as a regression: 2.9x is the improvement between revisions, not a cost to HTTP performance. Against no redaction at all, the cost is ~6.7µs per log line. The real config writes 2 lines per request, so ~13µs of extra CPU per request.
End to end that is below the noise floor. Three paired runs of 3,000 requests against a route doing no I/O — the worst case, since real endpoints wait on Bungie, Cosmos and Redis:
Mean −49µs, i.e. redaction-on measured faster on average. Run-to-run variance (~±190µs) swamps the ~13µs signal entirely. Measurable as CPU, not measurable as latency.
Verification
pnpm test935 passed / 1 skipped ·pnpm lintandpnpm typecheckclean.Beyond the suite, seven sentinels were driven through the real Express +
pino-http+log.jsstack underNODE_ENV=productionand the captured stdout grepped. None survive. Method, path, status, response time, request id and trace id all remain legible.That capture is worth calling out, because the unit tests passed while the OAuth code was still leaking. Pino's request serializer also emits Express's parsed
req.query, holding the samecodeas the URL — but a hand-built test request has noqueryproperty, so the serializer never copied one and the test asserted about a shape that does not occur in production.redactQuerycloses it.Three mutations were run rather than trusting a green suite:
set-cookieandtokens.blobfrom the key list failed their tests...newUserto the sign-up log line failed the success-logging teststdSerializers.req— the regression above — failed the field-set testMigration
Code path: nothing to do. Jobs already in Redis carry full documents, but
#senddestructures only the three identifiers so they still process, and the subscriber no longer spreads them into a log line.Exposure: one deploy step. Corrected after review — "nothing needs draining" was true for correctness and wrong about exposure.
removeOnCompletekeeps completed jobs 24h andremoveOnFailkeeps failed ones 7 days, and those retained payloads contain Bungierefresh_tokens. After deploying, clean them out once:Not baked into startup on purpose — it is a one-time action, and running it on every boot would permanently discard job history that is useful for debugging. Waiting and delayed jobs are deliberately left alone: they are pending sends, they process correctly in the old format, and they age out on their own.
Judgment call worth a second opinion
phoneNumberanddisplayNameare left unredacted on purpose — the consent and claim-check paths log them as the delivery audit trail, and censoring them would blind it. Happy to flip this if you would rather they were censored too.Left open
Acceptance criterion 6 — reviewing historical log access and retention, and rotating credentials where exposure warrants it. That is operational and yours to make. Given session cookies and OAuth authorization codes were going to logs on every sign-in, it is the criterion I would treat as most urgent.
🤖 Generated with Claude Code
https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf