security(redaction): mask credential-bearing URLs, args and headers in agent config (PEN-2747) - #1561
Merged
Merged
Conversation
added 2 commits
August 31, 2026 02:04
…n agent config (PEN-2747)
paperclip's own MCP server is an in-process stdio bridge, so the
mcp-gateway response scrubber from PEN-2370 never runs on it. The
agent-facing API has its own redactor -- `redactAgentSecrets` ->
`redactAgentConfigPayload` -> `sanitizeRecord` -- and that redactor is a
key-name denylist whose fallthrough returns an unrecognized key's string
value verbatim.
Three near-misses lined up so that credential material inside a URL left
the API in the clear:
- `url` is in no tier. `SECRET_TIER2_STEMS` spells `base[-_]?url`,
which needs the literal `base`, so `baseUrl` matched and a plain
`url` did not -- nor `endpoint`, `webhookUrl`, `serverUrl`,
`proxyUrl`.
- The repo already owned `URI_CREDENTIAL_RE` and never called it here:
it was wired only into `redactSensitiveText`, which `sanitizeRecord`
reaches only for `COMMAND_PAYLOAD_KEY_RE` keys.
- `COMMAND_ARGS_PAYLOAD_KEY_RE` covers `commandArgs|command_args|argv`
but not `args`, which is the spelling real MCP stdio configs use
(`services/tool-access.ts`). `argv` was masked; the identical value
under `args` was not.
Fix the value, not the key name: a denylist over key names fails open on
the fifth spelling, and `adapterConfig.mcpServers.*.url` is precisely
where an agent's k8s MCP upstream is swapped for a privileged `ns-rw` or
`admin` tier.
Masking is surgical -- only the credential component is replaced, so
scheme, principal, host, port and path survive and a credential-free URL
round-trips byte-identical. Knowing which upstream an agent points at is
the diagnostic value these read paths exist for.
The `headers` map gets the allowlist treatment the variable map already
has, because `Authorization` was masked only incidentally (via the
"auth" Tier-1 stem), leaving `X-Tenant-Signature` and every other vendor
spelling in the clear. A denylist over header names has no bounded
vocabulary to enumerate.
`restoreRedactedAdapterValue` moves from an equality test to a substring
test: the URI rule masks a component, so a round-tripped value now
*contains* the sentinel rather than equalling it, and an equality test
would persist a broken upstream URL.
Refs PEN-2370 (ask 3, criteria (a)+(b2)).
Signed-off-by: Cto <cto@paperclip.blockcast.net>
…PEN-2747) The suite asserted the leak as expected output. The one test covering `mcpServers.*.url` used a credential-free fixture, so it read as correct while the redactor passed a userinfo credential straight through, and `expectNoPlaintextSecrets` only grepped for the fixture's own four secrets -- none of which was carried in a URL, an `args` element, or a header spelled anything other than `Authorization`. The four new credential shapes go on the SHARED fixture rather than into bespoke tests, so every `expectNoPlaintextSecrets` case sweeps them instead of only the routes someone thought to cover. Measured against the pre-fix redactor that turns 1 failure into 11: the four new explicit cases, the header-allowlist assertion, and six mutating-route leak-guard sweeps that had no credential-bearing material to catch before. The `x-safe-routing-header` assertion flips to masked. That is the allowlist working as intended -- headers are credential-bearing by construction the way the variable map is, and a benign routing header being over-masked on a read path is recoverable, while a denylist over header names has no bounded vocabulary to enumerate. Verified the negative direction too: the credential-free `gbrain` URL round-trips byte-identical, so the rule masks a credential component rather than blanking URLs. Signed-off-by: Cto <cto@paperclip.blockcast.net>
Author
Author
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 27a33ab
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The structured sanitizer now masks credential-bearing URI components by value shape rather than relying on an incomplete key-name list.
argsis scoped to agent configuration, avoiding a regression in generic event payload redaction semantics.- Header handling uses a small benign allowlist, and the PATCH round-trip guard preserves existing values when a partially redacted URL is submitted back.
- Tests add URL userinfo, query credentials, non-Authorization headers, and MCP stdio argument coverage, including shared-fixture sweeps.
Recommended Action
- Merge when the repository's required CI checks are green.
Author
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 27a33ab
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The structured sanitizer now masks credential-bearing URI components by value shape rather than relying on an incomplete key-name list.
argsis scoped to agent configuration, avoiding a regression in generic event payload redaction semantics.- Header handling uses a small benign allowlist, and the PATCH round-trip guard preserves existing values when a partially redacted URL is submitted back.
- Tests add URL userinfo, query credentials, non-Authorization headers, and MCP stdio argument coverage, including shared-fixture sweeps.
Recommended Action
- Merge when the repository's required CI checks are green.
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Linked Issues or Issue Description
Refs PEN-2370 (ask 3, criteria (a)+(b2) — the response-content axis, on a surface the gateway scrubber does not sit on).
Internal tracking: PEN-2747. Related internal: PEN-2620, PEN-2429.
Related PR (different module, no overlap): #1556 touches
packages/mcp-gateway; this PR touchesserver/src/redaction.ts. No file overlap.Underlying problem (no public issue exists; following the bug-report shape):
redactAgentConfigPayloadreturns credential material in the clear when it is carried in a URL's userinfo or query component, in anargsarray, or in any request header not spelledAuthorization.sanitizeRecord's fallthrough (redaction.ts) hands an unrecognized key's string value back untouched. Three near-misses lined up:urlis in no tier —SECRET_TIER2_STEMSspellsbase[-_]?url, which requires the literalbase, sobaseUrlmatched and a plainurldid not (norendpoint,webhookUrl,serverUrl,proxyUrl).URI_CREDENTIAL_REalready existed but was wired only intoredactSensitiveText, whichsanitizeRecordreaches only forCOMMAND_PAYLOAD_KEY_REkeys — aurlvalue never got there.COMMAND_ARGS_PAYLOAD_KEY_REcoverscommandArgs|command_args|argvbut notargs, the spelling real MCP stdio configs use (services/tool-access.ts).argvwas masked; the identical value underargswas not.mcpServers.*.urlused a credential-free fixture, so it asserted the pass-through as correct output, andexpectNoPlaintextSecretsonly grepped for four fixture secrets, none of them URL-borne.No credential value was retrieved, quoted, or committed anywhere in this change. The finding was derived from source plus execution of the repo's own redaction module against synthetic values; all fixture secrets in this PR are invented strings.
What Changed
redaction.ts— value-shaped URI masking on the structured path. NewredactUriCredentialsInValue, called fromsanitizeValue, applies the existingURI_CREDENTIAL_REplus a new replacing sibling for query/fragment credential params. This coversurl/endpoint/webhookUrl/serverUrl/proxyUrlin one place rather than by enumerating key names.argsadded, gated onagentConfigrather than widened intoCOMMAND_ARGS_PAYLOAD_KEY_RE. A bareargsarray in a generic event payload is not command argv (tool-call arguments, job parameters), and this module already excludes that deliberately — see the "does not treat bare args payloads as command args" case inredaction.test.ts. Inside an agent config the key is unambiguous, so the widening is scoped there and only there.headersmasked allowlist-style, matching how the variable map is already handled.Authorizationhad been masked only incidentally (it contains the "auth" Tier-1 stem), leavingX-Tenant-Signatureand every other vendor spelling in the clear. The exemption list is short and closed (content negotiation, cache control, user agent).routes/agents.ts— round-trip guard fixed.restoreRedactedAdapterValuemoves from an equality test to a substring test: the URI rule masks a component, so a round-tripped value now contains the sentinel rather than equalling it. Left as equality, a caller PATCHing a previously-read config would persist a broken upstream URL — the same class of failure as a sentinel written back over live config, in a new shape.expectNoPlaintextSecretscase sweeps them, four new explicit assertions, and thex-safe-routing-headerassertion flipped to masked.Verification
The new tests were verified to fail against the unfixed redactor, which is the check that matters here — a regression test can pass against unfixed code and prove nothing. Reverting only the two source files to their pre-fix state and re-running takes the suite from 1 failure to 11:
Authorizationheader,args),expectNoPlaintextSecretssweeps that previously had no credential-bearing material to catch — which is the point of putting the fixtures on the shared object rather than in bespoke tests.Negative direction checked too: the credential-free
gbrainURL round-trips byte-identical, confirming the rule masks a credential component rather than blanking URLs.Risks
Low-to-moderate, and the moderate part is deliberate over-masking on a read path.
headersvalues outside the exemption list are now masked, including benign routing headers such as the fixture'sx-safe-routing-header. This is the allowlist working as designed — a denylist over header names has no bounded vocabulary to enumerate — but it is a visible behavioural change for anyone reading an agent config expecting a routing header value. Over-masking a response is recoverable; under-masking is not.redactUriCredentialsInValueruns on every sanitized string, not just agent configs. It is gated on the://scheme separator, so prose containingtoken=is untouched, and a bareuser@host(no:) or a credential-freehttps://host:8080/path(no@) cannot match. The 61 passing generic-path tests confirm no collateral change.Scope honesty — what this PR does NOT close. The
hire_agentapproval read path returns a real agent's liveadapterConfigundercompany_scope:read(auto-allowed to every same-company agent), whileGET /agents/:idrequiresagent_config:read. This PR closes the credential-value leak on that path —redactApprovalPayloadByTyperouteshire_agentthroughredactAgentConfigPayload, so every fix here applies there too. It does not equalize the two gates, so the residual disclosure is which upstream and which principal an agent is pointed at, not the credential. That is an authorization change in a different subsystem with a different risk profile and is tracked separately rather than bundled in here.Model Used
Claude Opus 4.5 (
claude-opus-4-5), extended thinking, via Claude Code with tool use and code execution. Model ID as reported by the running harness; 1M context.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template