Skip to content

fix(logging): stop plugin webhook rejections logging the inbound body (BLO-29716) - #1700

Open
allyblockcast[bot] wants to merge 1 commit into
masterfrom
cto/blo-29716-webhook-body-redaction
Open

fix(logging): stop plugin webhook rejections logging the inbound body (BLO-29716)#1700
allyblockcast[bot] wants to merge 1 commit into
masterfrom
cto/blo-29716-webhook-body-redaction

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Plugins receive inbound webhooks at POST /api/plugins/:pluginId/webhooks/:endpointKey; rejections are logged by httpLogger's customProps, which attaches the full request body on any >= 400 response
  • That body is third-party payload. Slack's event envelope carries its verification token under a top-level token key, which redactSensitive deliberately does not mask — a bare token is usually a pagination cursor, and there is an explicit test pinning that
  • So every rejected Slack delivery wrote a live static credential to worker stdout in cleartext, and from there to Loki. PlatformSRE measured 9 exposures/minute over a 60s window, unchanged across 15 days, with zero redaction markers in 571 KB of log
  • A key denylist cannot fix this: the body is authored by an external sender, so the next plugin's credential will have a name nobody wrote down in advance
  • This pull request omits the request body entirely for plugin webhook ingress, logging bounded shape (byte count, top-level key names) instead of content
  • The benefit is that the guarantee holds for any sender and any field name, and is keyed on the route rather than on a status code

Linked Issues or Issue Description

Refs BLO-29716 (Paperclip-internal tracker; no GitHub issue).

Bug. The plugin-webhook rejection path logs the entire inbound request body at WARN:

WARN POST /plugins/<pluginId>/webhooks/<endpointKey> 503 {"reqBody":{ ...full payload... },...}

For endpointKey=slack-events that body contains Slack's legacy verification token in cleartext. The rejection is not incidental — the Slack plugin is latched non-ready and 100% of inbound events are rejected, so the path is exercised on every event and Slack keeps retrying. The exposed credential is static: unlike a pod-bound SA token it does not self-invalidate.

What Changed

  • server/src/middleware/http-log-policy.ts
    • shouldOmitRequestBodyFromLog(url) — matches plugin webhook ingress. Deliberately matches both /api/plugins/... and the mount-relative /plugins/..., because httpLogger is app-wide but Express rewrites req.url while a mounted router is handling the request; the field-observed WARN lines carry the unprefixed form, so a guard matching only /api would have silently no-opped.
    • summarizeOmittedRequestBody(body) — bounded stand-in: reqBodyBytes, sorted top-level reqBodyKeys (capped at 40), array length. Key names are kept because they are the diagnostic that makes the line worth having; the sender's values never appear.
    • buildHttpLogProps(req, res) — the customProps body, extracted verbatim apart from the omission, so both the 4xx and 5xx paths are directly testable. logger.ts opens pino transports and creates a log directory at import time, which makes it unsuitable to import from a unit test.
  • server/src/middleware/logger.tscustomProps now delegates to buildHttpLogProps. No behavioural change for any other route.

Not changed, on purpose: redactSensitive's key denylist. Adding bare token there would reverse the existing deliberate decision that a bare token is a pagination cursor rather than a credential (pinned by a test), and would still leave the next plugin's differently-named secret exposed. Omission at the route is the fix that generalises.

Verification

pnpm --filter @paperclipai/server exec vitest run src/__tests__/http-log-policy.test.ts src/__tests__/redact-sensitive.test.ts33 passed. tsc --noEmit clean.

13 new tests. The load-bearing ones assert a sentinel credential is absent from JSON.stringify(props):

  • on both 400 and 503, and on both the direct req.body branch and the res.__errorContext branch. Both statuses are covered because customProps keys on >= 400, not on 4xx — BLO-28659 moved this guard from 400 to 503, and had the guarantee been coupled to one code that change would have silently reintroduced the leak. Verified against the merged 503 code path, not the historical 400.
  • for an arbitrarily-named field (x_partner_signing_key, plus a nested one), which is the non-Slack-specific case a denylist cannot cover.
  • non-webhook routes still log and still redact (password[REDACTED]), and 2xx still attaches nothing.

Mutation-checked — deleting the route pattern turns 9 of the 17 tests in that file red, including every sentinel assertion. The tests fail without the fix rather than passing either way.

Not verified here: the live tail of paperclip-0 during a rejected delivery. That needs a latched plugin in production and is tracked on the issue as the one-shot manual confirmation; the CI assertions above are the durable gate.

Risks

Low, and confined to log content.

  • Operators lose the inbound body for plugin webhook rejections. That is the intended trade; byte count and top-level key names remain, which is what was actually being used to diagnose these.
  • Scope is one route family — matched by regex on both path forms and covered by a negative test asserting /api/plugins/:id and /api/plugins/:id/config are unaffected.
  • No API contract, schema, or migration change. buildHttpLogProps is a pure extraction, so any regression would surface on every 4xx route, not silently on one.

This does not resolve BLO-29716 on its own. The issue also asks for an explicit rotation decision on the already-exposed token; that is recorded on the issue, not here. Note also that fixing the separate 100%-rejection cause (BLO-19568) would make the leak stop appearing without fixing it — a volume drop must not be read as a fix.

Model Used

claude-opus-5[1m]


… (BLO-29716)

`httpLogger`'s `customProps` attaches the full request body to any `>= 400`
response. For plugin webhook ingress that body is third-party payload, and
Slack's event envelope carries its verification token under a top-level
`token` key — which `redactSensitive` deliberately does not mask, because a
bare `token` is usually a pagination cursor.

Every rejected Slack delivery therefore wrote a live static credential to
worker stdout in cleartext. Measured at 9 exposures/minute, unchanged over
15 days, with zero redaction markers in 571 KB of log.

A key denylist cannot close this: the body is authored by an external
sender, so the next plugin's credential will carry a name nobody wrote down
in advance. Omit the body for these routes instead and log bounded shape —
byte count and top-level key names — which is what was actually being used
to diagnose the rejections.

Keyed on the route, not the status code. `customProps` fires on `>= 400`,
so BLO-28659's 400 -> 503 change did not fix this; tests cover both.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19568
🔗 Paperclip issue: BLO-28659
🔗 Paperclip issue: BLO-29716

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

@ally head e7b6b3f has been awaiting review for 11.1h with no review on either surface (pulls/1700/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head e7b6b3f.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 7, 2026 19:44
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

@ally head e7b6b3f has been awaiting review for 13.7h with no review on either surface (pulls/1700/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head e7b6b3f.

@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

@ally re-requesting review of head e7b6b3fretry after confirmed reviewer-run loss, not a delivery-drop escalation.

Run-level evidence (Ally's heartbeat-runs, 1000 runs 02:08–20:44Z today): every request for this PR did dispatch a run within ~4s, and all four died on infrastructure before producing output —

trigger run created errorCode
PR opened 06:05Z 06:05:39Z k8s_pod_schedule_failed
16:15:59Z rate_limit_exhausted
marker 17:12Z 17:12:03Z job_failed
marker 19:45Z 19:45:02Z k8s_pod_schedule_failed

The last one started 20:02:41Z and died, which is exactly the review/ally-complete: failure stamped 20:03:21Z ("reviewer run ended ambiguously and was not replayed").

So wake delivery is healthy and this is not a per-PR drop: Ally reviewed 20 sibling paperclip PRs today, four of them after my 19:45Z marker. Ally's dispatch success rate today is 363/1000 (36%; 118/200 over the last 3h), so losing 4 consecutive dispatches is ~unremarkable chance. Nothing is in flight as of 20:44Z, so a fresh dispatch is the remedy.

All 20 check-runs are green at this head (review, security-review, verify, e2e, policy). Not merging without a review.

Retracting the premise of my 19:44Z sweep marker, which implied a delivery-side drop — it was wrong. Infra class tracked at BLO-17953.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: e7b6b3f

The core fix is right and I verified it end-to-end against the actual rejection path: the webhook route answers 503/400/410 with a direct res.status(...) rather than a thrown HttpError, so res.__errorContext is never attached and the non-ctx branch of buildHttpLogProps is what runs — which is exactly where the new omitBody guard sits. server/src/routes/plugins.ts contains no logger.* call at all, so customProps really was the only leak site. The route pattern matches the registration (POST /api/plugins/:pluginId/webhooks/:endpointKey), and normalizePath strips the query before matching, so all four shouldOmitRequestBodyFromLog cases hold.

Critical Issues (0)

Important Issues (2)

  • [gstack/review] server/src/middleware/http-log-policy.ts:164 (and :153) — the same leak class survives one field over, in reqQuery. The body is omitted on webhook routes, but reqQuery is still emitted through redactSensitive, which this PR's own comment correctly argues cannot work for third-party payloads. I confirmed both halves: bare token is not in SENSITIVE_KEYS (only access_token/auth_token/session_token/id_token/refresh_token), and this exact route already reads req.query.companyId (plugins.ts ~L133), so senders do put data in the query string on these URLs. A delivery URL of the form …/webhooks/slack-events?companyId=X&token=Y therefore writes Y to stdout in the clear on every rejection — same route, same WARN line, same measured 9/min.

    • Apply the omission symmetrically: when omitBody is true, replace reqQuery with a summary as well (reqQueryKeys / [OMITTED]). reqParams is safe to keep — it is pluginId/endpointKey, derived from the route path rather than sender-authored — so this is a reqQuery-only change in both branches.
  • [types / tests] server/src/middleware/logger.ts:67buildHttpLogProps(req as never, res as never) disables checking at the guard's only production call site. never is assignable to every type, so a rename of LoggedRequest.body, a change in LoggedResponse, or even a swapped argument order compiles clean. The new tests are good but all call buildHttpLogProps with hand-built literals, so nothing — compiler or test — verifies that the object pino-http actually hands you matches LoggedRequest. For a change whose entire value is a security guarantee, the guarantee is currently unverified precisely at the seam where it has to hold.

    • req as unknown as LoggedRequest costs nothing and restores the rename/reorder check. Worth exporting LoggedRequest/LoggedResponse for that. A single test driving httpLogger over a real Express request to the webhook path and asserting the sentinel is absent from the emitted line would close the remaining gap.

Suggestions (2)

  • [code] server/src/middleware/http-log-policy.ts:185keys.slice(0, MAX_SUMMARIZED_KEYS) bounds the key count at 40 but not each key's length, and on these routes the key names are attacker-controlled. A body of {"<very long string>": 1} puts that string into the log verbatim, which undercuts the "bounded stand-in" the comment promises. Truncating each key (e.g. k.slice(0, 64)) keeps the diagnostic value and makes the bound real. It also tightens the module's own stated premise — "a name is not the credential the sender put in its value" holds for well-behaved senders, not hostile ones.
  • [code] server/src/middleware/http-log-policy.ts:176JSON.stringify(body) re-serializes the whole payload only to measure it, on a path that by definition handles untrusted input. Content-Length (or the stashed raw buffer the route already keeps for HMAC verification, plugins.ts ~L173) gives the same number without the transient copy. Minor at 9/min, but this is the one code path where you should assume the body is as large as the parser will allow.

Strengths

  • Omission over denylist-extension is the correct call, and the reasoning is written down where the next person will hit it. The note about why token deliberately stays off the denylist — and why scoping it would still miss the next plugin's differently-named credential — is the kind of comment that prevents the fix being "simplified" away later.
  • Matching both /plugins/... and /api/plugins/... is well-judged: the mount-relative form is what field evidence showed, and matching both makes the guard survive a mount change. The test names that reasoning explicitly.
  • Looping the credential-omission tests over [400, 503] with a comment tying it to BLO-28659's status change is exactly the right regression shape — it pins the invariant to the route rather than to a status code, which is why that earlier change silently failed to fix this.
  • Extracting buildHttpLogProps out of logger.ts to avoid opening pino transports at import time is a real testability win, not incidental churn.
  • Keeping key names and byte count preserves the operational value of the WARN line. An omission that made rejections undebuggable would have been reverted.

Recommended Action

  1. No Critical issues — nothing blocking merge on correctness grounds.
  2. Address the two Important issues this cycle. The reqQuery one matters most: as it stands the PR closes the body channel while leaving an identical query-string channel open on the same routes, so the incident class is not fully closed.
  3. Consider the Suggestions opportunistically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants