refactor: introduce CompositeKeychain and deprecate Keychain.root (OWASP MCP10)#1210
Open
manjunathshiva wants to merge 1 commit into
Open
Conversation
…ASP MCP10)
Per OWASP MCP Top 10 (2025) item MCP10 - Context Injection &
Over-Sharing. The process-wide `Keychain.root` static encouraged
implicit cross-session secret sharing in a server that can serve
many MCP sessions concurrently. This change adds explicit-ownership
primitives and a non-breaking deprecation path off the static.
Why this matters: the previous design let any code path register a
secret on a singleton readable by every other session in the same
process. Under the HTTP transport (many MCP sessions per process),
this widened the blast radius of any leak. The existing JSDoc on
Keychain itself flagged the static as "a bigger refactor we should
do later". This PR is that refactor, staged for one-release
deprecation so downstream embedders can migrate at their own pace.
src/common/keychain.ts
- New `CompositeKeychain` class: read-through union of N delegates
for cases where one logger needs to redact across multiple
ownership scopes at once (typically a bootstrap keychain composed
with a per-session keychain). `register` writes to the FIRST
delegate only; the rest are read-only sources. This keeps
ownership unambiguous: each composite has exactly one writable
backing keychain.
- `Keychain.root` and `registerGlobalSecretToRedact` are kept but
marked `@deprecated`. They now write to a module-local fallback
keychain (not a class static), making the transitional nature of
the shim clear to readers. The contract for callers is unchanged
- the old API continues to work for one release.
src/common/config/parseUserConfig.ts
- `parseUserConfig` return gains a `secrets: Keychain` field. The
returned keychain is pre-populated with every secret discovered
in the parsed config (Atlas credentials, connection string, TLS
file paths, etc.). The caller owns it and is responsible for
threading it into loggers / sessions.
- For one-release backward compat, every secret is ALSO registered
into the deprecated `Keychain.root` so downstream code that still
reads from the global keeps working unchanged.
- The previous private helper `registerKnownSecretsInRootKeychain`
is renamed `registerKnownSecrets` and accepts the target keychain
as a parameter.
src/lib.ts
- `CompositeKeychain` is added to the public exports alongside the
existing `Keychain` and `registerGlobalSecretToRedact`. Nothing is
removed.
Tests:
- tests/unit/common/keychain.test.ts: new CompositeKeychain test
suite covers read-union, write-target-is-first-delegate, full
fan-out clear, and refusal of zero-delegate constructions. A new
per-instance-isolation test guards against accidental reintroduction
of cross-Keychain sharing. The existing `Keychain.root` +
`registerGlobalSecretToRedact` deprecated-shim tests are preserved
(with cleanup before/after) to document the back-compat contract.
- tests/unit/common/config.test.ts: the two `toStrictEqual`
assertions on `parseUserConfig` return values now destructure
`secrets` before comparing the rest (the new field is additive
but breaks deep-equality), plus assertions that the returned
keychain is a real `Keychain` (and is empty when no secrets were
present). One failure-path assertion gains the same destructure
+ an empty-keychain check.
api-extractor reports regenerated.
NOT breaking changes:
- `Keychain.root` and `registerGlobalSecretToRedact` keep working.
- `parseUserConfig({...})` return type gains a new `secrets` field;
existing destructures that pull only `{ parsed, warnings, error }`
keep working. Full-equality assertions (`toStrictEqual`) need to
account for the new field — the most common case in test code,
flagged here for downstream test maintenance.
A subsequent release can complete the migration by removing
`Keychain.root` and `registerGlobalSecretToRedact`.
nirinchev
requested changes
May 27, 2026
nirinchev
left a comment
Collaborator
There was a problem hiding this comment.
I am not sure what this achieves. Instead of storing the secrets in a single place, we store it in multiple ones, but what is the benefit for users? My intuition would be that if something was registered as a secret in session 1, we shouldn't log it as plaintext in session 2.
Contributor
|
This PR has gone 30 days without any activity and meets the project's definition of "stale". This will be auto-closed if there is no new activity over the next 30 days. If the issue is still relevant and active, you can simply comment with a "bump" to keep it open, or add the label "not_stale". Thanks for keeping our repository healthy! |
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.
Per OWASP MCP Top 10 (2025) item MCP10 - Context Injection & Over-Sharing. The process-wide
Keychain.rootstatic encouraged implicit cross-session secret sharing in a server that can serve many MCP sessions concurrently. This change adds explicit-ownership primitives and a non-breaking deprecation path off the static.Why this matters: the previous design let any code path register a secret on a singleton readable by every other session in the same process. Under the HTTP transport (many MCP sessions per process), this widened the blast radius of any leak. The existing JSDoc on Keychain itself flagged the static as "a bigger refactor we should do later". This PR is that refactor, staged for one-release deprecation so downstream embedders can migrate at their own pace.
src/common/keychain.ts
CompositeKeychainclass: read-through union of N delegates for cases where one logger needs to redact across multiple ownership scopes at once (typically a bootstrap keychain composed with a per-session keychain).registerwrites to the FIRST delegate only; the rest are read-only sources. This keeps ownership unambiguous: each composite has exactly one writable backing keychain.Keychain.rootandregisterGlobalSecretToRedactare kept but marked@deprecated. They now write to a module-local fallback keychain (not a class static), making the transitional nature of the shim clear to readers. The contract for callers is unchangedsrc/common/config/parseUserConfig.ts
parseUserConfigreturn gains asecrets: Keychainfield. The returned keychain is pre-populated with every secret discovered in the parsed config (Atlas credentials, connection string, TLS file paths, etc.). The caller owns it and is responsible for threading it into loggers / sessions.Keychain.rootso downstream code that still reads from the global keeps working unchanged.registerKnownSecretsInRootKeychainis renamedregisterKnownSecretsand accepts the target keychain as a parameter.src/lib.ts
CompositeKeychainis added to the public exports alongside the existingKeychainandregisterGlobalSecretToRedact. Nothing is removed.Tests:
Keychain.root+registerGlobalSecretToRedactdeprecated-shim tests are preserved (with cleanup before/after) to document the back-compat contract.toStrictEqualassertions onparseUserConfigreturn values now destructuresecretsbefore comparing the rest (the new field is additive but breaks deep-equality), plus assertions that the returned keychain is a realKeychain(and is empty when no secrets were present). One failure-path assertion gains the same destructureapi-extractor reports regenerated.
NOT breaking changes:
Keychain.rootandregisterGlobalSecretToRedactkeep working.parseUserConfig({...})return type gains a newsecretsfield; existing destructures that pull only{ parsed, warnings, error }keep working. Full-equality assertions (toStrictEqual) need to account for the new field — the most common case in test code, flagged here for downstream test maintenance.A subsequent release can complete the migration by removing
Keychain.rootandregisterGlobalSecretToRedact.Proposed changes
Checklist