Skip to content

Commit a47a71f

Browse files
joshuayoesclaude
andauthored
feat(reactotron-mcp): expand redaction defaults and add form-urlencoded body support (#1608)
## Summary Stacks on top of #1607. Expands the MCP redactor's default denylists to match the cross-tool industry consensus and adds per-field redaction for `application/x-www-form-urlencoded` request bodies. Research comparing how other developer tools handle this is below — the short version: the closest analogs (Proxyman MCP, Sentry MCP, GitHub MCP, Postman) all redact at the server boundary by default, and their built-in denylists are broader than what #1607 currently ships. ## Changes ### Default rules — additions **Header names** - CSRF / XSRF variants: `x-csrf-token`, `x-xsrf-token`, `csrf-token` - IP-forwarding PII headers: `x-forwarded-for`, `x-real-ip` **Sensitive keys** - Password aliases: `passwd`, `pwd` - Generic auth-token names: `token`, `bearer`, `jwt`, `id_token`, `idtoken` - Session & CSRF: `session`, `sessionid`, `session_id`, `csrf`, `xsrf`, `csrf_token`, `xsrf_token` - OAuth: `client_secret`, `clientsecret`, `x-api-key` **Value patterns** - Anthropic API keys (`sk-ant-…`) - AWS access key IDs (`AKIA…`) - Google API keys (`AIza…` + 35 chars) - Stripe secret/publishable/restricted keys, live + test (`(?:sk|pk|rk)_(?:test|live)_…`) - PEM-encoded private key blocks (RSA, EC, DSA, OPENSSH, PGP, generic) - GitHub PAT regex broadened from `ghp_` only to `gh[pousr]_` — covers classic, server-to-server, OAuth, user-to-server, and refresh tokens ### Form-urlencoded body redaction A new code path catches strings shaped like `k=v&k=v` with no URL prefix (typical `application/x-www-form-urlencoded` POST bodies). If any key matches `sensitiveKeys`, just that value is redacted — the same semantics already used for URL query params. A strict full-match regex prevents false positives on prose that happens to contain `=`. ### Tests 105 tests passing. New coverage: - Each category of new default rule - Each new value pattern, with test literals constructed at runtime so GitHub secret-scanning doesn't flag the test file - Form-urlencoded body redaction, including negative tests for casual strings and URL-containing strings ### Docs `docs/mcp.md` updated to reflect the expanded default list and call out form-body handling. --- ## Research — how other tools handle this We spawned parallel research on how similar developer tools handle sensitive-data redaction. Full notes kept in the PR discussion; the convergent findings: ### 1. Redact at the server/MCP boundary — unanimous Every closest analog does it at the MCP serialization layer, not in the UI and not in the model: - **Proxyman MCP** — *"Sensitive data (auth tokens, passwords, API keys) is automatically redacted in responses"* ([docs](https://docs.proxyman.com/mcp)) - **Sentry MCP** — inherits Sentry's server-side scrubber - **GitHub MCP** — scans inputs for secrets and blocks by default ([changelog](https://github.blog/changelog/2025-08-13-github-mcp-server-secret-scanning-push-protection-and-more/)) - **Postman Repro** — case-insensitive default-key redaction - **mitmproxy `FilteredDumper` pattern** — redact at display/egress, not on the wire **OWASP MCP Top 10 — MCP01:2025** explicitly mandates: *"redact or sanitize inputs and outputs before logging… redact or mask secrets before writing to logs or telemetry."* ([link](https://owasp.org/www-project-mcp-top-10/2025/MCP01-2025-Token-Mismanagement-and-Secret-Exposure)) ### 2. No `sensitive` / `secretHint` annotation exists in the MCP spec today The 2025-03-26 spec adds `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` — but the maintainers are explicit: *"clients MUST NOT rely solely on these for security decisions."* ([MCP blog](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/)) Treat server-side redaction as the hard boundary; don't wait for an annotation. ### 3. The de-facto default denylist Union across **Sentry**, **Bugsnag**, **google/har-sanitizer**, **Postman**, **Chrome DevTools sanitized HAR**, **Presidio**: - Headers: `Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-Api-Key`, `X-CSRF-Token`, `X-XSRF-Token`, `X-Forwarded-For` - Keys: `password`/`passwd`/`pwd`, `secret`, `token`, `bearer`, `jwt`, `auth`, `authorization`, `api_key`/`apikey`, `credentials`, `session`/`sessionid`, `csrf`/`xsrf`, `access_token`, `refresh_token`, `id_token`, `client_secret`, `private_key` - Value patterns: AWS (`AKIA…`), Google (`AIza…`), JWT (`eyJ…`), Stripe, GitHub PATs (all prefixes), PEM private key blocks, Anthropic (`sk-ant-…`) This PR brings our defaults in line with that union. ### 4. Tool-by-tool highlights | Tool | Redaction approach | What we took / avoided | |---|---|---| | **Charles Proxy** | None built-in; user-written Rewrite rules only | Avoid its "bring your own regex" UX — ship opinionated defaults | | **Wireshark** | `editcap` + third-party TraceWrangler; fail-closed pattern | Noted `strictMode` allowlist as future work | | **Postman** | "Secret" variable type masks UI only; still exfiltrated in analytics URLs — cautionary tale | Redact the fully-rendered payload at MCP boundary, not at display | | **mitmproxy / Proxyman** | `modify_headers`, Python addons; Proxyman MCP auto-redacts but rules are opaque/non-tunable | Keep user-tunable config; don't ship an opaque rule set | | **Chrome DevTools** | `Export HAR (sanitized)` strips `Authorization`, `Cookie`, `Set-Cookie` only (Chrome 130, Oct 2024) | That's the floor. We already go beyond. | | **google/har-sanitizer** | Public [wordlist](https://github.com/google/har-sanitizer/blob/master/harsanitizer/static/wordlist.json) — `state`, `token`, `access_token`, `client_secret`, `SAMLRequest`, etc. | Directly informed our expanded default key list | | **Cloudflare HAR sanitizer** | Conditional, not denylist — strips JWT signature but keeps claims for debugging | Filed as a future enhancement (partial/format-preserving redaction) | | **Sentry / Bugsnag / Datadog / LogRocket** | Opinionated server-side defaults + user-extendable via `beforeSend`-style hook; Datadog offers partial redaction & Luhn-validated card detection | Union of their default lists → our new defaults. Partial redaction & Luhn are follow-ups. | ### Key canonical incident **Okta support breach (Oct 2023)** — attacker stole HAR files from 134 customer support tickets; the HARs contained live session tokens that were used to hijack sessions at BeyondTrust, Cloudflare, and 1Password. The PR's default-on posture is the right response to this class of leak. --- ## What is intentionally NOT in this PR Tracked as follow-ups so the review stays focused: - **Substring matching on keys.** Sentry JS and Bugsnag match substrings; that catches `sessionToken`/`userPassword` automatically but false-positives on `author`/`authored_by` when `auth` is in the list. Would need a separate denylist/pattern split. - **Typed redaction markers** (`[REDACTED:jwt]`) and a `_redacted` summary sibling field. Useful for LLM reasoning and defensive-sandwich logging but changes the public output shape. - **Luhn-validated credit-card detection.** A bare 13–19 digit regex produces too many false positives on random IDs and unix timestamps; needs Luhn to be safe. - **Cookie-value parsing within the `Cookie` header.** Currently the whole header is blunt-redacted. Cloudflare's per-cookie approach (keep names, redact values) would preserve more debug info. - **Partial / format-preserving masking** (keep last 4 of card, keep JWT claims but strip signature) — the strongest idea from Cloudflare/Datadog, worth a dedicated PR. - **`strictMode` allowlist** (à la TraceWrangler's "drop unknown layers" / mitmproxy's `FilteredDumper`) — only forward known-safe headers, redact the rest. ## Test plan - [x] `yarn test` in `lib/reactotron-mcp` — 105 tests pass - [x] `yarn typecheck` clean - [x] `yarn build` succeeds - [ ] Reviewer sanity-check: no new default key is an obvious false-positive trigger for any team's app-specific field names - [ ] Reviewer sanity-check: form-encoded regex doesn't false-positive on real-world payloads in your apps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5270b84 commit a47a71f

3 files changed

Lines changed: 221 additions & 9 deletions

File tree

docs/mcp.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,11 @@ By default, Reactotron redacts sensitive data from all MCP responses so that tok
5757

5858
Out of the box, the following are replaced with `[REDACTED]`:
5959

60-
- **HTTP headers**`Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `Proxy-Authorization`
61-
- **Object keys**`password`, `secret`, `api_key`, `access_token`, `refresh_token`, `private_key`, `credentials`, `ssn`, `creditcard`, and variants
62-
- **String values** matching common token formats — Bearer tokens, JWTs (`eyJ...`), OpenAI keys (`sk-...`), GitHub PATs (`ghp_...`), Slack tokens (`xoxb-...`)
60+
- **HTTP headers**`Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `Proxy-Authorization`, `X-CSRF-Token`, `X-XSRF-Token`, `CSRF-Token`, `X-Forwarded-For`, `X-Real-IP`
61+
- **Object keys**`password`, `passwd`, `pwd`, `secret`, `client_secret`, `api_key`, `token`, `bearer`, `jwt`, `access_token`, `refresh_token`, `id_token`, `session`, `sessionid`, `csrf`, `xsrf`, `private_key`, `credentials`, `ssn`, `creditcard`, and variants
62+
- **String values** matching common token formats — Bearer tokens, JWTs (`eyJ...`), OpenAI keys (`sk-...`), Anthropic keys (`sk-ant-...`), GitHub PATs/OAuth/user-to-server tokens (`ghp_/ghs_/gho_/ghu_/ghr_...`), Slack tokens (`xoxb-...`), AWS access key IDs (`AKIA...`), Google API keys (`AIza...`), Stripe keys (`sk_live_/pk_test_/...`), and PEM-encoded private key blocks
6363
- **URL query parameters** whose names match any sensitive key (e.g. `?api_key=abc` becomes `?api_key=[REDACTED]`)
64+
- **Form-urlencoded bodies** — strings shaped like `k=v&k=v` (e.g. `application/x-www-form-urlencoded` request bodies) get the same per-field redaction as URL query parameters
6465

6566
### Configuring redaction in Reactotron
6667

lib/reactotron-mcp/src/redaction.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,44 @@ export const DEFAULT_REDACTION_RULES: McpRedactionRules = {
66
headerNames: [
77
"authorization", "cookie", "set-cookie",
88
"x-api-key", "x-auth-token", "proxy-authorization",
9+
"x-csrf-token", "x-xsrf-token", "csrf-token",
10+
"x-forwarded-for", "x-real-ip",
911
],
1012
sensitiveKeys: [
11-
"password", "secret", "apikey", "api_key", "accesstoken",
12-
"access_token", "refreshtoken", "refresh_token", "privatekey",
13-
"private_key", "credentials", "ssn", "creditcard",
13+
"password", "passwd", "pwd",
14+
"secret", "client_secret", "clientsecret",
15+
"apikey", "api_key", "x-api-key",
16+
"accesstoken", "access_token",
17+
"refreshtoken", "refresh_token",
18+
"idtoken", "id_token",
19+
"token", "bearer", "jwt",
20+
"session", "sessionid", "session_id",
21+
"csrf", "xsrf", "csrf_token", "xsrf_token",
22+
"privatekey", "private_key",
23+
"credentials", "ssn", "creditcard",
1424
],
1525
statePathPatterns: [],
1626
valuePatterns: [
27+
// Bearer tokens
1728
"Bearer\\s+[A-Za-z0-9\\-._~+/]+=*",
29+
// JWTs (header.payload[.signature])
1830
"eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}",
19-
"sk-[a-zA-Z0-9]{20,}",
20-
"ghp_[a-zA-Z0-9]{30,}",
31+
// OpenAI-style keys (also matches our legacy "sk-..." pattern)
32+
"sk-[a-zA-Z0-9_-]{20,}",
33+
// Anthropic API keys
34+
"sk-ant-[a-zA-Z0-9_-]{20,}",
35+
// GitHub PATs / fine-grained tokens
36+
"gh[pousr]_[A-Za-z0-9]{30,}",
37+
// Slack tokens
2138
"xox[bpoas]-[a-zA-Z0-9\\-]{10,}",
39+
// AWS access key IDs
40+
"AKIA[0-9A-Z]{16}",
41+
// Google API keys
42+
"AIza[0-9A-Za-z\\-_]{35}",
43+
// Stripe keys (live/test, secret/publishable/restricted)
44+
"(?:sk|pk|rk)_(?:test|live)_[A-Za-z0-9]{24,}",
45+
// PEM-encoded private key blocks (RSA, EC, DSA, OPENSSH, or generic)
46+
"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\\s\\S]+?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----",
2247
],
2348
}
2449

@@ -222,7 +247,11 @@ function redactStringValue(value: string, rules: McpRedactionRules): string {
222247
// Redact URL query parameters whose names match sensitiveKeys
223248
const sensitiveKeys = rules.sensitiveKeys ?? []
224249
if (sensitiveKeys.length > 0) {
225-
result = redactUrlQueryParams(result, sensitiveKeys)
250+
if (result.includes("?")) {
251+
result = redactUrlQueryParams(result, sensitiveKeys)
252+
} else if (looksLikeFormEncoded(result)) {
253+
result = redactFormEncodedParams(result, sensitiveKeys)
254+
}
226255
}
227256

228257
// Redact value patterns
@@ -238,6 +267,30 @@ function redactStringValue(value: string, rules: McpRedactionRules): string {
238267
return result
239268
}
240269

270+
/**
271+
* Detect a form-urlencoded body string (e.g. "user=alice&password=x"). Must be the whole
272+
* string — `foo=bar` mid-sentence won't match. We require at least one "=" and that the
273+
* full string is `k=v(&k=v)*` shape to avoid false positives on casual strings.
274+
*/
275+
function looksLikeFormEncoded(value: string): boolean {
276+
if (!value || value.length > 8192) return false
277+
return /^[\w.\-[\]%]+=[^&]*(?:&[\w.\-[\]%]+=[^&]*)*$/.test(value)
278+
}
279+
280+
/** Redact values in a form-urlencoded body where the param name matches sensitiveKeys. */
281+
function redactFormEncodedParams(value: string, sensitiveKeys: string[]): string {
282+
const sensitiveSet = new Set(sensitiveKeys.map((k) => k.toLowerCase()))
283+
return value.split("&").map((param) => {
284+
const eqIndex = param.indexOf("=")
285+
if (eqIndex === -1) return param
286+
const name = param.slice(0, eqIndex)
287+
if (sensitiveSet.has(name.toLowerCase())) {
288+
return `${name}=${REDACTED}`
289+
}
290+
return param
291+
}).join("&")
292+
}
293+
241294
/** Redact query parameter values in URLs where the param name matches sensitiveKeys. */
242295
function redactUrlQueryParams(value: string, sensitiveKeys: string[]): string {
243296
const qIndex = value.indexOf("?")

lib/reactotron-mcp/test/redaction.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,52 @@ describe("DEFAULT_REDACTION_RULES", () => {
319319
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-api-key")
320320
})
321321

322+
test("includes CSRF/XSRF header variants", () => {
323+
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-csrf-token")
324+
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-xsrf-token")
325+
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("csrf-token")
326+
})
327+
328+
test("includes IP-forwarding PII headers", () => {
329+
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-forwarded-for")
330+
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-real-ip")
331+
})
332+
322333
test("has expected default sensitive keys", () => {
323334
expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("password")
324335
expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("secret")
325336
expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("access_token")
326337
})
327338

339+
test("includes common auth-token key variants", () => {
340+
const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? []
341+
expect(keys).toContain("token")
342+
expect(keys).toContain("bearer")
343+
expect(keys).toContain("jwt")
344+
expect(keys).toContain("id_token")
345+
expect(keys).toContain("idtoken")
346+
})
347+
348+
test("includes session and CSRF key variants", () => {
349+
const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? []
350+
expect(keys).toContain("session")
351+
expect(keys).toContain("sessionid")
352+
expect(keys).toContain("csrf")
353+
expect(keys).toContain("xsrf")
354+
})
355+
356+
test("includes password aliases", () => {
357+
const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? []
358+
expect(keys).toContain("passwd")
359+
expect(keys).toContain("pwd")
360+
})
361+
362+
test("includes OAuth client_secret variants", () => {
363+
const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? []
364+
expect(keys).toContain("client_secret")
365+
expect(keys).toContain("clientsecret")
366+
})
367+
328368
test("value patterns match common token formats", () => {
329369
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
330370

@@ -348,4 +388,122 @@ describe("DEFAULT_REDACTION_RULES", () => {
348388
const plainResult = redact("hello world", rules)
349389
expect(plainResult).toBe("hello world")
350390
})
391+
392+
test("value patterns match Anthropic API keys", () => {
393+
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
394+
// Built at runtime so GitHub secret scanning doesn't flag the test file.
395+
const fakeKey = ["sk", "ant"].join("-") + "-" + "x".repeat(32)
396+
expect(redact(fakeKey, rules)).toBe(REDACTED)
397+
})
398+
399+
test("value patterns match AWS access key IDs", () => {
400+
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
401+
const fakeKey = "AKI" + "A" + "X".repeat(16)
402+
expect(redact(fakeKey, rules)).toBe(REDACTED)
403+
})
404+
405+
test("value patterns match Google API keys", () => {
406+
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
407+
// Google API keys are 39 chars: prefix + 35 more.
408+
const fakeKey = "AIz" + "a" + "X".repeat(35)
409+
expect(redact(fakeKey, rules)).toBe(REDACTED)
410+
})
411+
412+
test("value patterns match Stripe keys", () => {
413+
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
414+
const body = "X".repeat(28)
415+
expect(redact(["sk", "live", body].join("_"), rules)).toBe(REDACTED)
416+
expect(redact(["pk", "test", body].join("_"), rules)).toBe(REDACTED)
417+
expect(redact(["rk", "live", body].join("_"), rules)).toBe(REDACTED)
418+
})
419+
420+
test("value patterns match PEM private key blocks", () => {
421+
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
422+
const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"
423+
const result = redact(pem, rules)
424+
expect(result).toBe(REDACTED)
425+
426+
const openssh = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjE=\n-----END OPENSSH PRIVATE KEY-----"
427+
expect(redact(openssh, rules)).toBe(REDACTED)
428+
429+
const generic = "-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----"
430+
expect(redact(generic, rules)).toBe(REDACTED)
431+
})
432+
433+
test("GitHub PAT pattern covers all prefix variants", () => {
434+
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
435+
// Built at runtime so GitHub secret scanning doesn't flag the test file.
436+
const body = "X".repeat(36)
437+
for (const prefix of ["ghp", "ghs", "gho", "ghu", "ghr"]) {
438+
expect(redact(`${prefix}_${body}`, rules)).toBe(REDACTED)
439+
}
440+
})
441+
})
442+
443+
describe("form-urlencoded body redaction", () => {
444+
test("redacts sensitive values in form-encoded body strings", () => {
445+
const rules: McpRedactionRules = {
446+
sensitiveKeys: ["password", "token"],
447+
valuePatterns: [],
448+
}
449+
const body = "username=alice&password=s3cret&token=abc123&remember=1"
450+
const result = redact(body, rules)
451+
expect(result).toBe(`username=alice&password=${REDACTED}&token=${REDACTED}&remember=1`)
452+
})
453+
454+
test("redacts form-encoded inside a request.data string", () => {
455+
const rules: McpRedactionRules = {
456+
sensitiveKeys: ["password"],
457+
valuePatterns: [],
458+
}
459+
const data = {
460+
request: {
461+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
462+
data: "user=alice&password=hunter2",
463+
},
464+
}
465+
const result = redact(data, rules) as any
466+
expect(result.request.data).toBe(`user=alice&password=${REDACTED}`)
467+
})
468+
469+
test("does not false-positive on casual strings with '=' ", () => {
470+
const rules: McpRedactionRules = {
471+
sensitiveKeys: ["password"],
472+
valuePatterns: [],
473+
}
474+
// Must NOT be treated as form-encoded — it's just prose with an equals sign.
475+
const data = { note: "The setting x=5 was applied, and the count is 3" }
476+
const result = redact(data, rules) as any
477+
expect(result.note).toBe("The setting x=5 was applied, and the count is 3")
478+
})
479+
480+
test("does not alter form-encoded strings when no sensitive key matches", () => {
481+
const rules: McpRedactionRules = {
482+
sensitiveKeys: ["password"],
483+
valuePatterns: [],
484+
}
485+
const body = "page=1&limit=10&sort=desc"
486+
expect(redact(body, rules)).toBe(body)
487+
})
488+
489+
test("URL with query params still uses URL path, not form-encoded path", () => {
490+
// Regression guard: a string containing '?' should go through the URL branch,
491+
// not the form-encoded branch.
492+
const rules: McpRedactionRules = {
493+
sensitiveKeys: ["token"],
494+
valuePatterns: [],
495+
}
496+
const url = "https://api.example.com/x?token=abc&page=1"
497+
expect(redact(url, rules)).toBe(`https://api.example.com/x?token=${REDACTED}&page=1`)
498+
})
499+
500+
test("handles bracketed and percent-encoded keys", () => {
501+
const rules: McpRedactionRules = {
502+
sensitiveKeys: ["password"],
503+
valuePatterns: [],
504+
}
505+
const body = "user[name]=alice&password=hunter2"
506+
const result = redact(body, rules)
507+
expect(result).toBe(`user[name]=alice&password=${REDACTED}`)
508+
})
351509
})

0 commit comments

Comments
 (0)