Found while reviewing #2826. Not caused by that PR — pre-existing, and it affects every API-key-authenticated route, so filing separately.
What's wrong: apps/api/src/services/sentry.ts:24-30 scrubs exactly two request headers:
const headers = event?.request?.headers;
if (headers) {
for (const k of Object.keys(headers)) {
if (k.toLowerCase() === 'authorization' || k.toLowerCase() === 'cookie') headers[k] = '[redacted]';
}
}
x-api-key is not in that list. Every API-key-authenticated surface passes its credential in that header — organization API keys (brz_...) and partner service principals (brz_sp_...). @sentry/node's HTTP integration attaches event.request.headers, and app.onError (apps/api/src/index.ts:1003-1020) calls captureException on any unhandled throw. So a single 500 on an API-key route can put a live, unexpired credential into Sentry, where it is retained and visible to anyone with project access.
The intent was already there. The extra scrubber immediately below (sentry.ts:31-40) does know about this credential shape:
if (SENSITIVE_KEYS.has(k.toLowerCase()) || (typeof v === 'string' && v.startsWith('brz_'))) {
extra[k] = '[redacted]';
}
The brz_ prefix check was written for exactly this class of secret and was never extended to headers. This reads as an oversight in #1379 B3 rather than a deliberate scope decision.
Suggested fix: in scrubEvent, redact by value as well as by name — any header whose value matches /^brz_/, plus x-api-key and x-breeze-csrf by name. Redacting on the value catches future header names for free. scrubEvent is already exported for test, and sentry.test.ts has the harness, so this is a small change with a direct regression test.
Worth checking during the fix: whether any credential has already landed in the retained Sentry events. If so, those keys should be rotated rather than just scrubbed going forward.
Severity: not remotely exploitable on its own — it needs an unhandled 500 on an API-key route — but the payload is a working credential and the blast radius is whoever can read the Sentry project. I'd treat it as a should-fix rather than a drop-everything.
Staying open — no code change yet.
Found while reviewing #2826. Not caused by that PR — pre-existing, and it affects every API-key-authenticated route, so filing separately.
What's wrong:
apps/api/src/services/sentry.ts:24-30scrubs exactly two request headers:x-api-keyis not in that list. Every API-key-authenticated surface passes its credential in that header — organization API keys (brz_...) and partner service principals (brz_sp_...).@sentry/node's HTTP integration attachesevent.request.headers, andapp.onError(apps/api/src/index.ts:1003-1020) callscaptureExceptionon any unhandled throw. So a single 500 on an API-key route can put a live, unexpired credential into Sentry, where it is retained and visible to anyone with project access.The intent was already there. The
extrascrubber immediately below (sentry.ts:31-40) does know about this credential shape:The
brz_prefix check was written for exactly this class of secret and was never extended to headers. This reads as an oversight in #1379 B3 rather than a deliberate scope decision.Suggested fix: in
scrubEvent, redact by value as well as by name — any header whose value matches/^brz_/, plusx-api-keyandx-breeze-csrfby name. Redacting on the value catches future header names for free.scrubEventis already exported for test, andsentry.test.tshas the harness, so this is a small change with a direct regression test.Worth checking during the fix: whether any credential has already landed in the retained Sentry events. If so, those keys should be rotated rather than just scrubbed going forward.
Severity: not remotely exploitable on its own — it needs an unhandled 500 on an API-key route — but the payload is a working credential and the blast radius is whoever can read the Sentry project. I'd treat it as a should-fix rather than a drop-everything.
Staying open — no code change yet.