Skip to content

feat: add MCP redaction filtering for sensitive data#1607

Open
silasjmatson wants to merge 3 commits intomasterfrom
feat/mcp-redaction
Open

feat: add MCP redaction filtering for sensitive data#1607
silasjmatson wants to merge 3 commits intomasterfrom
feat/mcp-redaction

Conversation

@silasjmatson
Copy link
Copy Markdown
Contributor

@silasjmatson silasjmatson commented Apr 23, 2026

Warning: This was vibe-coded, we'll want to verify the architecture is what we want and review thoroughly.

Description

Add a secure-by-default redaction system that filters sensitive data from all MCP resource and tool responses. Uses a two-key model where default rules apply automatically and disabling requires agreement from both the client app and the Reactotron desktop app.

Redaction engine:

  • Key matching: redacts object keys matching sensitiveKeys (case-insensitive)
  • Header matching: redacts HTTP headers matching headerNames
  • Value patterns: regex-based detection of Bearer tokens, JWTs, API keys
  • State path patterns: dot-separated paths with wildcard support
  • URL query params: redacts query param values matching sensitiveKeys
  • Handles circular references, nested objects, and arrays

Client apps can send additionalRules (always allowed) or removeRules/ disableRedaction (requires server permission via settings modal).

Desktop app: settings modal for editing rules and client permissions, redaction status indicator (shield icon) in footer, config persisted via electron-store.

44 tests (30 unit + 14 integration). Docs updated.

Screenshot 2026-04-23 at 2 27 16 PM Screenshot 2026-04-23 at 2 27 44 PM Screenshot 2026-04-23 at 2 30 43 PM

Please verify the following:

  • yarn build-and-test:local passes
  • I have added tests for any new features, if relevant
  • README.md (or relevant documentation) has been updated with your changes

Add a secure-by-default redaction system that filters sensitive data
from all MCP resource and tool responses. Uses a two-key model where
default rules apply automatically and disabling requires agreement
from both the client app and the Reactotron desktop app.

Redaction engine:
- Key matching: redacts object keys matching sensitiveKeys (case-insensitive)
- Header matching: redacts HTTP headers matching headerNames
- Value patterns: regex-based detection of Bearer tokens, JWTs, API keys
- State path patterns: dot-separated paths with wildcard support
- URL query params: redacts query param values matching sensitiveKeys
- Handles circular references, nested objects, and arrays

Client apps can send additionalRules (always allowed) or removeRules/
disableRedaction (requires server permission via settings modal).

Desktop app: settings modal for editing rules and client permissions,
redaction status indicator (shield icon) in footer, config persisted
via electron-store.

44 tests (30 unit + 14 integration). Docs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ed 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>
@joshuayoes
Copy link
Copy Markdown
Contributor

🐛 Redaction gap: request bodies aren't redacted by key

What I saw (manual QA on iOS sim + Android emulator):

Fired an API call with:

fetch("https://jsonplaceholder.typicode.com/posts?api_key=X&page=2", {
  method: "POST",
  headers: { Authorization: "Bearer abc...", Cookie: "session=..." },
  body: JSON.stringify({ password: "hunter2", api_key: "x", accessToken: "y",
                         note: "embedded sk-ABCDEFGHIJKLMNOPQRSTUVWX" }),
})

Reading reactotron://timeline/api.response via MCP, the response body is fully redacted, but the request body leaks:

"request": {
  "data": "{\"password\":\"hunter2\",\"api_key\":\"x\",\"accessToken\":\"y\",\"note\":\"embedded [REDACTED] ...\"}",
  "headers": { "authorization": "[REDACTED]", "cookie": "[REDACTED]" },
  "params": { "api_key": "[REDACTED]", "page": "2" }
}

Headers, URL query params, and the sk- token inside note (via valuePatterns) are redacted correctly. But password, api_key, accessToken in the body string pass through.

Root cause: fetch({ body: JSON.stringify(...) }) gives the networking plugin a string, cached verbatim as tronRequest.data (lib/reactotron-react-native/src/plugins/networking.ts:92-96). The redactor walks object keys but only applies valuePatterns to strings (lib/reactotron-mcp/src/redaction.ts:219-239), so the JSON-shaped string is never parsed and key-level redaction never runs on the body.

Who's affected: anyone using fetch / axios / apisauce with a JSON body — auth endpoints ({ password }), token refresh, payment flows, etc. will leak to the MCP client.

Suggested fix: in redactStringValue or summarizeNetworkEntry, detect strings that look like JSON (start with { or [, or Content-Type application/json), JSON.parseredactJSON.stringify, fall back to valuePatterns if parsing throws.

@joshuayoes
Copy link
Copy Markdown
Contributor

🐛 State path patterns silently fail when requesting a subtree

Repro:

  1. Added a Redux slice so state.redactionTest.auth.tokens = { access: "...", refresh: "..." }.
  2. In the MCP redaction settings modal, added redactionTest.auth.tokens.* to State Path Patterns.
  3. Via MCP: request_state({ path: "redactionTest.auth" }).

Actual:

{ "username": "alice", "password": "[REDACTED]", "api_key": "[REDACTED]",
  "tokens": { "access": "access-raw-value", "refresh": "refresh-raw-value" } }

tokens.access / tokens.refresh pass through despite the pattern.

Expected: both redacted. Confirmed the pattern matcher itself works — changing the pattern to tokens.* (relative to the returned subtree) does redact correctly.

Root cause: lib/reactotron-mcp/src/tools.ts:162 calls applyRedaction(stateValue, ...) with the path-scoped subtree, and redact() defaults currentPath = "" (lib/reactotron-mcp/src/redaction.ts:109). So tokens.access is walked with currentPath = "tokens.access" — the user-written absolute pattern redactionTest.auth.tokens.* has no chance of matching.

Why it matters: the settings-modal UI encourages absolute paths (the placeholder example is auth.tokens.*, suggesting patterns anchored to the state root). But those patterns silently no-op as soon as a caller uses a path-scoped request — and LLMs are strongly encouraged to do so by the request_state tool description ("Always specify a path to avoid oversized responses"). State path patterns effectively only work when reading reactotron://state/current (which returns the whole tree).

Suggested fix: thread the requested path through applyRedaction to redact() as the initial currentPath:

// tools.ts
const redactedState = applyRedaction(stateValue, server, serverRedactionConfig, clientId, args.path ?? "")
// redaction.ts
export function applyRedaction(data, server, config, clientId, basePath = "")
// -> return redact(data, rules, basePath)

Same treatment for any other path-scoped code paths that return subtrees.

@joshuayoes
Copy link
Copy Markdown
Contributor

ℹ️ State path patterns have limited effective surface

Related to the state-path-pattern bug above: in the example app, request_state({ path: "" }) returns only { repo, logo } even though request_state_keys({ path: "" }) correctly reports ["logo", "repo", "error", "redactionTest"]. The error and redactionTest slices initialize with empty or nested-empty payloads and don't appear in the root state snapshot that reactotron-redux pushes.

Combined with the subtree bug, this means state-path patterns written as absolute paths only fire in a narrow window: the reactotron://state/current resource, and only for slices that reactotron-redux bothers to serialize at the root. Worth double-checking whether this is a reactotron-redux serialization quirk or something else — either way, state-path patterns as-designed are more of a last-resort filter than a primary line of defense.

Not blocking this PR — flagging for context.

@joshuayoes
Copy link
Copy Markdown
Contributor

⚠️ Low severity — AsyncStorage mutations leak when keys/values carry secrets

Caveat up front: AsyncStorage isn't encrypted storage, and no one should be stashing secrets there. But in practice people do — tokens, session blobs, cached auth headers — and the MCP server exposes these mutations verbatim via reactotron://asyncstorage, so it's worth pointing out what does and doesn't redact.

Repro:

AsyncStorage.multiSet([
  ["auth:password", "hunter2"],
  ["auth:api_key", "leaked-api-key"],
  ["auth:accessToken", "leaked-access-token"],
  ["auth:session", JSON.stringify({ password: "hunter2", api_key: "leaked",
                                   note: "Bearer abcdef1234567890ABCDEFGH" })],
])

Actual (via reactotron://asyncstorage):

{
  "pairs": [
    { "0": "auth:password",    "1": "hunter2" },
    { "0": "auth:api_key",     "1": "leaked-api-key" },
    { "0": "auth:accessToken", "1": "leaked-access-token" },
    { "0": "auth:session",     "1": "{\"password\":\"hunter2\",\"api_key\":\"leaked\",\"note\":\"[REDACTED]\"}" }
  ]
}

Only the Bearer value pattern inside the auth:session JSON string got caught. The bare key names (auth:password), the bare values (hunter2), and the password/api_key fields inside the JSON string body all pass through.

Why: the multiSet payload shape is { pairs: [{0: "auth:password", 1: "hunter2"}] }. The redactor walks object keys — pairs, 0, 1 — none of which match sensitive keys. String values are only tested against valuePatterns, and none of the default regexes match bare secrets or namespaced storage keys. Same root issue as the JSON-body gap for network requests.

Pragmatic suggestion:

  • In whatever shapes AsyncStorage mutations (summarizeCommand for asyncStorage.mutation), when serializing key / value pairs, split the key on common separators (. : _ /) and test each segment against sensitiveKeys — if any match, redact the value.
  • Reuse whatever JSON-string-aware fix ends up landing for the request-body gap here too — a value like {"password":"..."} should get parsed → redacted → re-stringified.

Not security-critical on its own (AsyncStorage isn't encrypted to begin with), but it's the third place where "string values carrying structured data" sidestep the redactor, so the fix probably lands once and covers all three.

One screen exercises every MCP redaction surface (network, Redux state,
logging, AsyncStorage). Each button emits a sensitive payload; the block
comment above each button documents the input and the expected MCP-side
redaction so future verification doesn't need an external QA doc.

Client-side mcpRedaction config sends all three optional relaxations so
the two-key permission model can be driven from the desktop settings
modal alone, without rebuilding the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants