From faf0382761299f837a5a3b8a732851ccba30d34e Mon Sep 17 00:00:00 2001 From: Joshua Yoes <37849890+joshuayoes@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:31:35 -0700 Subject: [PATCH] feat(reactotron-mcp): expand redaction defaults and add form-urlencoded body support Bring the MCP redactor closer to the industry consensus denylist used by Sentry, Bugsnag, Postman, Cloudflare, and google/har-sanitizer. Research comparing Charles, Wireshark, Postman, mitmproxy, Proxyman, Chrome DevTools, Sentry, and Datadog is summarized in the PR description. Default rules: - Add CSRF/XSRF and IP-PII header names (x-csrf-token, x-xsrf-token, csrf-token, x-forwarded-for, x-real-ip). - Add common auth/session key variants (token, bearer, jwt, id_token, session, sessionid, csrf, xsrf, passwd, pwd, client_secret). - Add value patterns for Anthropic keys, AWS access key IDs, Google API keys, Stripe live/test/restricted keys, and PEM private key blocks. - Broaden GitHub PAT regex from ghp_ only to gh[pousr]_ (classic, server, OAuth, user-to-server, refresh). Form-urlencoded bodies: - Strings shaped like `k=v&k=v` (no URL prefix) now get the same per-field redaction as URL query parameters. A strict full-match regex prevents false positives on prose containing `=`. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/mcp.md | 7 +- lib/reactotron-mcp/src/redaction.ts | 65 ++++++++- lib/reactotron-mcp/test/redaction.test.ts | 158 ++++++++++++++++++++++ 3 files changed, 221 insertions(+), 9 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 130e3216e..4e34ca5ad 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -57,10 +57,11 @@ By default, Reactotron redacts sensitive data from all MCP responses so that tok Out of the box, the following are replaced with `[REDACTED]`: -- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `Proxy-Authorization` -- **Object keys** — `password`, `secret`, `api_key`, `access_token`, `refresh_token`, `private_key`, `credentials`, `ssn`, `creditcard`, and variants -- **String values** matching common token formats — Bearer tokens, JWTs (`eyJ...`), OpenAI keys (`sk-...`), GitHub PATs (`ghp_...`), Slack tokens (`xoxb-...`) +- **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` +- **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 +- **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 - **URL query parameters** whose names match any sensitive key (e.g. `?api_key=abc` becomes `?api_key=[REDACTED]`) +- **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 ### Configuring redaction in Reactotron diff --git a/lib/reactotron-mcp/src/redaction.ts b/lib/reactotron-mcp/src/redaction.ts index 63d1334bc..2d333024f 100644 --- a/lib/reactotron-mcp/src/redaction.ts +++ b/lib/reactotron-mcp/src/redaction.ts @@ -6,19 +6,44 @@ export const DEFAULT_REDACTION_RULES: McpRedactionRules = { headerNames: [ "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", ], sensitiveKeys: [ - "password", "secret", "apikey", "api_key", "accesstoken", - "access_token", "refreshtoken", "refresh_token", "privatekey", - "private_key", "credentials", "ssn", "creditcard", + "password", "passwd", "pwd", + "secret", "client_secret", "clientsecret", + "apikey", "api_key", "x-api-key", + "accesstoken", "access_token", + "refreshtoken", "refresh_token", + "idtoken", "id_token", + "token", "bearer", "jwt", + "session", "sessionid", "session_id", + "csrf", "xsrf", "csrf_token", "xsrf_token", + "privatekey", "private_key", + "credentials", "ssn", "creditcard", ], statePathPatterns: [], valuePatterns: [ + // Bearer tokens "Bearer\\s+[A-Za-z0-9\\-._~+/]+=*", + // JWTs (header.payload[.signature]) "eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}", - "sk-[a-zA-Z0-9]{20,}", - "ghp_[a-zA-Z0-9]{30,}", + // OpenAI-style keys (also matches our legacy "sk-..." pattern) + "sk-[a-zA-Z0-9_-]{20,}", + // Anthropic API keys + "sk-ant-[a-zA-Z0-9_-]{20,}", + // GitHub PATs / fine-grained tokens + "gh[pousr]_[A-Za-z0-9]{30,}", + // Slack tokens "xox[bpoas]-[a-zA-Z0-9\\-]{10,}", + // AWS access key IDs + "AKIA[0-9A-Z]{16}", + // Google API keys + "AIza[0-9A-Za-z\\-_]{35}", + // Stripe keys (live/test, secret/publishable/restricted) + "(?:sk|pk|rk)_(?:test|live)_[A-Za-z0-9]{24,}", + // PEM-encoded private key blocks (RSA, EC, DSA, OPENSSH, or generic) + "-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\\s\\S]+?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----", ], } @@ -222,7 +247,11 @@ function redactStringValue(value: string, rules: McpRedactionRules): string { // Redact URL query parameters whose names match sensitiveKeys const sensitiveKeys = rules.sensitiveKeys ?? [] if (sensitiveKeys.length > 0) { - result = redactUrlQueryParams(result, sensitiveKeys) + if (result.includes("?")) { + result = redactUrlQueryParams(result, sensitiveKeys) + } else if (looksLikeFormEncoded(result)) { + result = redactFormEncodedParams(result, sensitiveKeys) + } } // Redact value patterns @@ -238,6 +267,30 @@ function redactStringValue(value: string, rules: McpRedactionRules): string { return result } +/** + * Detect a form-urlencoded body string (e.g. "user=alice&password=x"). Must be the whole + * string — `foo=bar` mid-sentence won't match. We require at least one "=" and that the + * full string is `k=v(&k=v)*` shape to avoid false positives on casual strings. + */ +function looksLikeFormEncoded(value: string): boolean { + if (!value || value.length > 8192) return false + return /^[\w.\-[\]%]+=[^&]*(?:&[\w.\-[\]%]+=[^&]*)*$/.test(value) +} + +/** Redact values in a form-urlencoded body where the param name matches sensitiveKeys. */ +function redactFormEncodedParams(value: string, sensitiveKeys: string[]): string { + const sensitiveSet = new Set(sensitiveKeys.map((k) => k.toLowerCase())) + return value.split("&").map((param) => { + const eqIndex = param.indexOf("=") + if (eqIndex === -1) return param + const name = param.slice(0, eqIndex) + if (sensitiveSet.has(name.toLowerCase())) { + return `${name}=${REDACTED}` + } + return param + }).join("&") +} + /** Redact query parameter values in URLs where the param name matches sensitiveKeys. */ function redactUrlQueryParams(value: string, sensitiveKeys: string[]): string { const qIndex = value.indexOf("?") diff --git a/lib/reactotron-mcp/test/redaction.test.ts b/lib/reactotron-mcp/test/redaction.test.ts index 165311860..c5620959e 100644 --- a/lib/reactotron-mcp/test/redaction.test.ts +++ b/lib/reactotron-mcp/test/redaction.test.ts @@ -319,12 +319,52 @@ describe("DEFAULT_REDACTION_RULES", () => { expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-api-key") }) + test("includes CSRF/XSRF header variants", () => { + expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-csrf-token") + expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-xsrf-token") + expect(DEFAULT_REDACTION_RULES.headerNames).toContain("csrf-token") + }) + + test("includes IP-forwarding PII headers", () => { + expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-forwarded-for") + expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-real-ip") + }) + test("has expected default sensitive keys", () => { expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("password") expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("secret") expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("access_token") }) + test("includes common auth-token key variants", () => { + const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? [] + expect(keys).toContain("token") + expect(keys).toContain("bearer") + expect(keys).toContain("jwt") + expect(keys).toContain("id_token") + expect(keys).toContain("idtoken") + }) + + test("includes session and CSRF key variants", () => { + const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? [] + expect(keys).toContain("session") + expect(keys).toContain("sessionid") + expect(keys).toContain("csrf") + expect(keys).toContain("xsrf") + }) + + test("includes password aliases", () => { + const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? [] + expect(keys).toContain("passwd") + expect(keys).toContain("pwd") + }) + + test("includes OAuth client_secret variants", () => { + const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? [] + expect(keys).toContain("client_secret") + expect(keys).toContain("clientsecret") + }) + test("value patterns match common token formats", () => { const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns } @@ -348,4 +388,122 @@ describe("DEFAULT_REDACTION_RULES", () => { const plainResult = redact("hello world", rules) expect(plainResult).toBe("hello world") }) + + test("value patterns match Anthropic API keys", () => { + const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns } + // Built at runtime so GitHub secret scanning doesn't flag the test file. + const fakeKey = ["sk", "ant"].join("-") + "-" + "x".repeat(32) + expect(redact(fakeKey, rules)).toBe(REDACTED) + }) + + test("value patterns match AWS access key IDs", () => { + const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns } + const fakeKey = "AKI" + "A" + "X".repeat(16) + expect(redact(fakeKey, rules)).toBe(REDACTED) + }) + + test("value patterns match Google API keys", () => { + const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns } + // Google API keys are 39 chars: prefix + 35 more. + const fakeKey = "AIz" + "a" + "X".repeat(35) + expect(redact(fakeKey, rules)).toBe(REDACTED) + }) + + test("value patterns match Stripe keys", () => { + const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns } + const body = "X".repeat(28) + expect(redact(["sk", "live", body].join("_"), rules)).toBe(REDACTED) + expect(redact(["pk", "test", body].join("_"), rules)).toBe(REDACTED) + expect(redact(["rk", "live", body].join("_"), rules)).toBe(REDACTED) + }) + + test("value patterns match PEM private key blocks", () => { + const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns } + const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----" + const result = redact(pem, rules) + expect(result).toBe(REDACTED) + + const openssh = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjE=\n-----END OPENSSH PRIVATE KEY-----" + expect(redact(openssh, rules)).toBe(REDACTED) + + const generic = "-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----" + expect(redact(generic, rules)).toBe(REDACTED) + }) + + test("GitHub PAT pattern covers all prefix variants", () => { + const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns } + // Built at runtime so GitHub secret scanning doesn't flag the test file. + const body = "X".repeat(36) + for (const prefix of ["ghp", "ghs", "gho", "ghu", "ghr"]) { + expect(redact(`${prefix}_${body}`, rules)).toBe(REDACTED) + } + }) +}) + +describe("form-urlencoded body redaction", () => { + test("redacts sensitive values in form-encoded body strings", () => { + const rules: McpRedactionRules = { + sensitiveKeys: ["password", "token"], + valuePatterns: [], + } + const body = "username=alice&password=s3cret&token=abc123&remember=1" + const result = redact(body, rules) + expect(result).toBe(`username=alice&password=${REDACTED}&token=${REDACTED}&remember=1`) + }) + + test("redacts form-encoded inside a request.data string", () => { + const rules: McpRedactionRules = { + sensitiveKeys: ["password"], + valuePatterns: [], + } + const data = { + request: { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + data: "user=alice&password=hunter2", + }, + } + const result = redact(data, rules) as any + expect(result.request.data).toBe(`user=alice&password=${REDACTED}`) + }) + + test("does not false-positive on casual strings with '=' ", () => { + const rules: McpRedactionRules = { + sensitiveKeys: ["password"], + valuePatterns: [], + } + // Must NOT be treated as form-encoded — it's just prose with an equals sign. + const data = { note: "The setting x=5 was applied, and the count is 3" } + const result = redact(data, rules) as any + expect(result.note).toBe("The setting x=5 was applied, and the count is 3") + }) + + test("does not alter form-encoded strings when no sensitive key matches", () => { + const rules: McpRedactionRules = { + sensitiveKeys: ["password"], + valuePatterns: [], + } + const body = "page=1&limit=10&sort=desc" + expect(redact(body, rules)).toBe(body) + }) + + test("URL with query params still uses URL path, not form-encoded path", () => { + // Regression guard: a string containing '?' should go through the URL branch, + // not the form-encoded branch. + const rules: McpRedactionRules = { + sensitiveKeys: ["token"], + valuePatterns: [], + } + const url = "https://api.example.com/x?token=abc&page=1" + expect(redact(url, rules)).toBe(`https://api.example.com/x?token=${REDACTED}&page=1`) + }) + + test("handles bracketed and percent-encoded keys", () => { + const rules: McpRedactionRules = { + sensitiveKeys: ["password"], + valuePatterns: [], + } + const body = "user[name]=alice&password=hunter2" + const result = redact(body, rules) + expect(result).toBe(`user[name]=alice&password=${REDACTED}`) + }) })