Skip to content

security(mcp-gateway): base64 is name-shaped, so validating the name is not enough (PEN-2370) - #1522

Merged
kkroo merged 3 commits into
masterfrom
security/pen-2370-name-position-base64
Aug 28, 2026
Merged

security(mcp-gateway): base64 is name-shaped, so validating the name is not enough (PEN-2370)#1522
kkroo merged 3 commits into
masterfrom
security/pen-2370-name-position-base64

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work, and those agents hold real credentials
  • The mcp-gateway proxies MCP tool responses back to agents; packages/mcp-gateway/src/response-scrub.ts is the layer that strips secret material out of them before an agent ever sees it
  • PEN-2370 exists because the read-only k8s grant returns spec.containers[].env in the clear, so one grant yields fleet-wide credentials — the scrubber is the control that closes that
  • The scrubber deliberately preserves the variable name where there is one (OPENAI_API_KEY: "<redacted>"), because a redaction that also destroys the name destroys the diagnostic use the grant exists for
  • That name-preserving path is the risky one: anything wrongly promoted into the name position gets printed in the clear, beside its own <redacted> marker — design note 4's "false assurance" failure
  • security(mcp-gateway): derive the env-scalar gate from one predicate, not two spellings (PEN-2370) #1518 merged at 05:46Z today closing the [LEAKED]=x spelling by validating the prefix against ENV_VAR_NAME. Four hours later the same defect was found in another spelling: base64 is name-shaped, so a padded blob passed the name check and was printed whole
  • This pull request fixes the distinguishing property rather than the spelling — a real KEY=VALUE has a value after the =, base64 padding does not — and asserts it once, in a constant shared by both name-preserving rules
  • The benefit is that the two paths can no longer drift apart, and the new tests check the structure of what was kept rather than a fixture list, so an encoding nobody has thought of fails them too

Linked Issues or Issue Description

  • Refs PEN-2370 — acceptance criteria (a2) and (b2)
  • Refs PEN-1680 — the operator rollout path this change still needs

Dedup search (prior PRs in this series, all reviewed before opening this one):

PR State Relationship
#1518 merged 05:46Z today Direct predecessor. Closed one spelling of this defect; this PR closes the next one and removes the duplication that caused the drift.
#1501 merged PEN-2370 (a1) — block-termination fail-opens in the YAML scrubber
#1449 merged PEN-2431 door #5 — container argv redaction
#1435 merged The original env-scrubbing layer this builds on
#1506 merged PEN-2370 door #7 — webhook mirror path (different surface)
#1457 closed Earlier default-deny attempt on the JSON path; superseded, not revived here

No open PR overlaps this change. #1509 (PEN-2527, GitHub egress scrub) touches a different module and does not conflict.

What Changed

  • Added REQUIRE_VALUE_AFTER_EQ in packages/mcp-gateway/src/response-scrub.ts — a single shared lookahead asserting that a value actually follows the =. A value ends at end-of-line, at a closing quote, or at a comment; terminal padding followed by any of those is not a value.
  • Both name-preserving rules now consume that one constantENV_KEY_VALUE_SCALAR (JSON path) and ENV_KEY_VALUE_ENTRY (YAML sequence path). Writing the rule twice is what produced the drift security(mcp-gateway): derive the env-scalar gate from one predicate, not two spellings (PEN-2370) #1518 was opened to fix, so it is one constant, not two spellings.
  • No change to the failure mode at either call site — both already fail closed when their pattern misses, so a rejected prefix redacts the whole scalar.
  • Replaced the superseded test with structural assertions plus 4 instance cases across all four name-printing paths (JSON scalar, bare and quoted YAML sequence entry, YAML scalar), and kept an explicit counterweight.

Context for the fix

#1518 replaced indexOf("=") !== -1 with "validate the prefix against ENV_VAR_NAME". dGhpc2lz…= is entirely legal name characters followed by an =, so the name-only check promoted a whole base64-encoded credential into the name position. Measured on origin/master (d741f104, #1518's own merge), canary in place of material:

                    json      yaml-seq
base64 +padding     LEAK      LEAK      "dGhpc2lzMATERIALCANARY=<redacted>"
base64 ==padding    LEAK      LEAK      "dGhpc2lzMATERIALCANARY=<redacted>"
jwt +padding        LEAK      LEAK      "eyJ….c2ln=<redacted>"

It was found by the method PEN-2370 ask 3 names as the control: after a remediation lands, go looking for another route to the same material rather than re-reading the patch. That same sweep — run again after Ally's review comment, on the suffix space rather than the patch — turned up a fourth spelling ("…="# note, no space before the #), which is why the committed guard splits the past-a-closing-quote case from the plain-scalar case.

Verification

  • Re-measured against unfixed source at this head (master bfc7d17's response-scrub.ts swapped in, tests unchanged):

    Tests  5 failed | 2 passed | 230 skipped (237)
    

    The 5 leak/invariant cases fail; both counterweights pass. A regression test that is green on unfixed code is not a regression test — and a counterweight must be green on both sides, which is what makes it a control rather than a fixture.

  • The structural assertion — whatever survives in the name position is a name, not material — checks that the kept prefix matches a name and is not the input with its padding shaved off. An encoding nobody has thought of fails this too.

  • Two counterweights, each pinning one half of the guard:

    • TOKEN=<base64> must still keep TOKEN. Without it, "redact every =-bearing scalar whole" satisfies every other assertion here while destroying design note 2.
    • TOKEN=pa#ss and TOKEN=#hash must still keep TOKEN, on the bare and quoted arms. Without it, "treat # as end-of-value" degenerates into redacting every entry whose value contains a hash — which satisfies the suffix cases above and quietly drops real names.
  • With the fix: 240 passed (240) across response-scrub + response-scrub.e2e.

  • tsc --noEmit -p packages/mcp-gateway/tsconfig.json clean (exit 0, no diagnostics). Working tree clean against the pushed head.

No credential value appears in this diff, and no pod was re-probed — the probe runs against the module's exported entry points with a synthetic canary.

Risks

Low risk to real traffic; the scope is narrower than the diff reads.

  • Every well-formed Kubernetes and Docker serialization was already clean before this commit and still is. Verified, not assumed:

    k8s   env: [{name,value}]      clean    (both sk- and base64 values)
    docker Config.Env: ["K=V"]     clean    (both)
    k8s   YAML env list            clean
    

    This closes a fallback-path defect, not a reachable incident. The shapes that leaked require a malformed entry — a bare blob where a KEY=VALUE belongs.

  • Regression risk is the counterweight direction: an over-tight guard would start redacting legitimate TOKEN=<base64> entries whole, destroying the diagnostic value of the grant. That is the failure this change could plausibly introduce, so it is asserted explicitly in the tests rather than left to review.

  • Residual NOT fixed here, stated so nobody reads the series as closed: a scalar with no = and no YAML indicator — an sk- key, an unpadded JWT, a hex token — still passes through in the clear on the JSON and YAML-scalar paths. Deliberately out of scope:

    1. Failing closed on plain scalars measurably corrupted non-k8s traffic — the module documents {"nodeId":9007199254740993,"ratio":1.0} being mangled, because redaction re-serializes the whole body and JSON.stringify rounds integers above 2^53. This gateway also proxies GitHub and Paperclip, where env: in prose is routine.
    2. A credential-shape matcher is precisely the denylist ask 3 (b2) forbids — "an allowlist wearing a denylist's clothes; it fails open on exactly the credential that does not look like one", as this module already says about argv.

    Proposed instead (recorded on PEN-2370, not bundled here): gate default-deny on the container list, reusing the inContainers flag the argv rule already threads. Inside a container spec a scalar env is unambiguously k8s and cannot corrupt GitHub/Paperclip traffic. That is a design change with its own blast radius and does not belong bolted onto a security fix.

  • Merging this is not a rollout. The deployed gateway still needs the operator-executed apply + rollout-restart on the PEN-1680 path. No agent, including me, can perform it.

Model Used

Claude Opus 5 (claude-opus-5), 1M context, extended thinking enabled, with tool use — run via Claude Code on the Claude Agent SDK.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have searched GitHub for duplicate or related PRs and linked them above

🤖 Generated with Claude Code

…is not enough (PEN-2370)

#1518 replaced `indexOf("=") !== -1` with "validate the prefix against
`ENV_VAR_NAME`" before preserving a variable name. That closed the
`[LEAKED]=x` spelling and left the identical defect open in another one:
base64 is name-shaped. `dGhpc2lz...=` is entirely legal name characters
followed by an `=`, so the name-only check promoted a whole base64-encoded
credential into the name position and printed it in the clear beside its
own `<redacted>` marker.

That is design note 4's false-assurance failure, inside the fix for it,
found hours after it merged -- and found by the method PEN-2370 ask 3
names as the control: after a remediation lands, go looking for another
route to the same material rather than re-reading the patch.

The distinguishing property is not the charset. It is that base64 padding
is *terminal*: a real `KEY=VALUE` has a VALUE after the `=`, padding does
not. `REQUIRE_VALUE_AFTER_EQ` asserts that once and is shared by both
name-preserving rules, so this cannot be fixed on one path only. Both
call sites already fail closed when their pattern misses, so a rejected
prefix redacts whole with no further change.

Tests are written as an invariant, not a fixture list. The test this
replaces asserted a general property ("keeps the name only where there is
one to keep") while checking two spellings, which is why it was green
while this leaked -- the same asymmetry ask 3 is about. The new
"whatever survives in the name position is a name, not material" case
checks the *structure* of what was kept, so an encoding nobody has
thought of fails it too. A counterweight (`TOKEN=<base64>` still keeps
`TOKEN`) stops the rule degenerating into "redact every `=`-bearing
scalar", which would satisfy every other assertion here while destroying
design note 2.

Verified: the 5 new leak/invariant cases fail against unfixed source and
the counterweight passes there (a regression test green on unfixed code
is not a regression test). With the fix, 239 passed (239) across
response-scrub + e2e; `tsc --noEmit` clean.

SCOPE, stated plainly: every well-formed Kubernetes and Docker
serialization was already clean before this commit and still is -- the
list-of-{name,value} shape, and `Config.Env` as `KEY=VALUE` strings. This
closes a fallback-path defect, not a reachable incident. A residual
remains and is NOT fixed here: a scalar with no `=` and no YAML indicator
(an `sk-` key, an unpadded JWT, a hex token) still passes through,
because failing closed on plain scalars measurably corrupted non-k8s
traffic (`JSON.stringify` rounds integers above 2^53) and a
credential-shape matcher is the denylist ask 3 (b2) forbids. Recorded on
PEN-2370 with a proposed container-list gate rather than bundled into a
security PR as a speculative change.

Refs: PEN-2370 ask 3 criteria (a2), (b2)
Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast
allyblockcast Bot requested a review from kkroo August 27, 2026 09:04
@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2370
🔗 Paperclip issue: PEN-1680

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 210ccc6

Critical Issues (1)

  • [native-codex] packages/mcp-gateway/src/response-scrub.ts:402REQUIRE_VALUE_AFTER_EQ treats an inline YAML comment as value content, so a name-shaped credential ending in base64 padding still matches ENV_KEY_VALUE_ENTRY and is emitted as the preserved name beside <redacted>. For example, - dGhpc2lzMATERIALCANARY= # comment matches the new lookahead; the same occurs for the quoted form. The new tests cover terminal padding at end-of-line but not YAML comments, leaving this leak path open.
    • Make the guard recognize YAML comments as non-value suffixes (while preserving # inside quoted values), or otherwise parse the scalar before applying the name-preserving rule. Add quoted and unquoted inline-comment regressions for terminal-padding material.

Important Issues (0)

Suggestions (0)

Strengths

  • The shared guard removes the duplicated name-preservation logic across JSON and YAML paths.
  • The base64-value counterweight protects legitimate KEY=VALUE entries from being redacted wholesale.

Recommended Action

  1. Fix the Critical issue before merge.
  2. Address Important issues this cycle.
  3. Consider Suggestions opportunistically.

…ontent (PEN-2370)

Ally's review found the name-preservation guard still open in a third
spelling. `REQUIRE_VALUE_AFTER_EQ` rejected "nothing but `=` padding to
the end of the *line*", and a YAML comment is line content that is not
value content -- so `- dGhpc2lz...= # note` put the whole base64
credential straight back into the preserved-name position, printed in
the clear beside its own `<redacted>` marker. Five characters of suffix
defeated it, and the quoted form did the same past its closing quote.
Confirmed against the regex before changing anything; the finding is
correct as written.

Twice now the guard has been right about the property -- base64 padding
is terminal -- and wrong about where the value ends. So the fix is that
distinction, not another case: the value ends at end-of-line, at a
closing quote, or at a comment.

Then the ask 3 control, applied to this fix rather than to the one it
replaces: after writing the comment clause I swept the suffix space
looking for another route instead of re-reading the patch, and found a
fourth. `- "dGhpc2lz="# c` -- a comment with no space before the `#` --
still leaked, because requiring whitespace before `#` is right for a
plain scalar and wrong past a closing quote, where nothing but a comment
can follow. The committed guard splits those two cases. The reviewer
asked for the whitespace rule; the sweep is what showed it needed an
exception, and the case it found was not in the review.

Diagnostics are held by counterweights, since "treat `#` as end of
value" would otherwise degenerate into redacting every entry whose value
contains a hash: `TOKEN=pa#ss`, `TOKEN=#hash` and their quoted forms all
still keep `TOKEN`. Stated plainly, the one place this over-redacts is
the JSON path, where `#` has no comment meaning and `TOKEN= #x` is a
real value. That is accepted rather than papered over -- a per-path
spelling of the guard is exactly the drift that produced this defect,
so one shared constant that is slightly conservative on one path is the
(b2) trade.

Tests extend the existing invariant along a second axis rather than
appending the two fixtures the review named. Suffix is now crossed with
material shape, because a fixture list only covers combinations someone
sat down and enumerated -- which is why the previous version was green
while this leaked.

Verified: the 4 material cases fail against the pre-fix source with the
new suffixes and pass with the fix; counterweights pass on both, as they
must. 240 passed (240) across response-scrub + e2e; `tsc --noEmit`
clean.

Refs: PEN-2370 ask 3 criteria (a1), (a2), (b2)
Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

Critical issue — confirmed and fixed in 3e7aba1

The finding is correct as written. I reproduced it against the regex before changing anything:

entry old guard
- dGhpc2lz…= redact whole ✅
- dGhpc2lz…= # comment name preserved — leaks
- "dGhpc2lz…=" # comment name preserved — leaks

The guard rejected "nothing but = padding to the end of the line", and an inline comment is line content that is not value content. Five characters of suffix put the whole credential back in the preserved-name position, printed beside its own <redacted> marker.

Twice now the guard has been right about the property — base64 padding is terminal — and wrong about where the value ends. So the fix is that distinction rather than another case: a value ends at end-of-line, at a closing quote, or at a comment.

const REQUIRE_VALUE_AFTER_EQ = `(?!=*(?:["']\\s*(?:#|$)|\\s*$|\\s+#))`;

One thing the review didn't have

After writing the comment clause I swept the suffix space rather than re-reading the patch — the control PEN-2370 ask 3 names — and it turned up a fourth spelling:

- "dGhpc2lzMATERIAL="# note    # no space before the '#' — still leaked

Your whitespace-before-# rule is right for a plain scalar and wrong past a closing quote, where nothing but a comment can follow. The committed guard splits those two cases; that's the third alternation branch above. Worth flagging because it's the shape the review's stated remedy would have left open.

Diagnostics held

"Treat # as end-of-value" would otherwise degenerate into redacting every entry whose value contains a hash, so counterweights pin it: TOKEN=pa#ss, TOKEN=#hash, TOKEN=<base64>== and their quoted forms all still keep TOKEN.

One accepted over-redaction, stated rather than papered over: on the JSON path # has no comment meaning, so TOKEN= #x is a real value and now redacts whole. I kept one shared constant anyway — a per-path spelling is precisely the drift that produced this defect, so the (b2) trade is one guard that is slightly conservative on one path.

Tests

Extended along a second axis rather than adding the two fixtures requested: suffix ("", " # note", "\t# note", " # note", "…"# note) crossed with material shape, over unquoted / double-quoted / single-quoted / env:-scalar forms. Quoted and unquoted inline-comment regressions for terminal-padding material are both covered, which is the ask — but as a cross-product, because a fixture list only covers combinations someone enumerated, and that asymmetry is why the previous version was green while this leaked.

Verified: the 4 material cases fail against the pre-fix source with the new suffixes and pass with the fix; counterweights pass on both, as a counterweight must. 240 passed (240) across response-scrub + e2e; tsc --noEmit clean.

Ready for re-review.

@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3e7aba1

Prior Findings Dispositioned (1)

  • prior:210ccc6 critical 1 — fixed — packages/mcp-gateway/src/response-scrub.ts:418 — the shared lookahead now treats terminal padding followed by YAML comment text, including a comment immediately after a closing quote, as having no value; the name-preserving regexes therefore fail closed and redact the whole scalar.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The shared value-presence guard closes the prior inline-comment bypass without duplicating the rule across JSON and YAML paths.
  • The test matrix covers material shape, quoting style, and comment suffixes, while retaining counterweights for real names and values containing #.
  • The implementation explicitly documents the remaining plain-scalar limitation and avoids broadening this narrowly scoped remediation into an unreviewed parser redesign.

Recommended Action

  1. No Critical issues found.
  2. No Important issues found.
  3. Consider Suggestions opportunistically.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: a302497

Prior Findings Dispositioned (1)

  • prior:210ccc6 critical 1 — fixed — packages/mcp-gateway/src/response-scrub.ts:418 — the shared lookahead now rejects terminal padding followed by YAML comment suffixes, including comments immediately after a closing quote; the name-preserving rules therefore fail closed and redact the entire scalar.

Critical Issues (0)

Important Issues (0)

Suggestions (1)

  • [tests] packages/mcp-gateway/src/response-scrub.test.ts:1146 — Add a quoted-YAML counterweight for a value beginning with whitespace and # (for example, - "TOKEN= #value"). In a quoted YAML scalar, # remains value content, but the shared \s+# branch intentionally redacts the whole entry. The behavior is safe, yet an explicit test would document the preservation tradeoff and prevent an accidental regression of the quoted parser semantics.

Strengths

  • The shared lookahead closes the prior inline-comment bypass across JSON and YAML name-preserving paths.
  • The test matrix covers padding shapes, quoting, comment suffixes, and legitimate base64 values while keeping the implementation narrow.
  • Focused verification passed: 237/237 response-scrub tests and tsc --noEmit completed without diagnostics.

Recommended Action

  1. No Critical issues found.
  2. No Important issues found.
  3. Consider Suggestions opportunistically.

@kkroo
kkroo added this pull request to the merge queue Aug 28, 2026
Merged via the queue into master with commit cd14342 Aug 28, 2026
22 checks passed
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.

1 participant