Repository navigation
3.9.0
Security
-
Closed a cross-principal session-binding collision (CWE-384) caused by a
blankAuthIdentitystable id. The session-binding fingerprint keys on a
per-identity stable id (method || 0x00 || stable_id,
src/session_binding.rs). When that stable id was blank (empty or
whitespace-only), two distinct principals produced byte-identical
fingerprints, so one caller's identity-bound session became usable by another
— reopening the CWE-384 hole previously closed for session binding.The five first-party producers that could emit a blank stable id are now
closed — mTLS extraction, configured API keys, hot-reloaded API keys,
verify_bearer_token, and OAuth claims:- mTLS (
extract_mtls_identity): a present-but-blank Subject CN no longer
yields a blank identity, and no longer shadows a usable DNS SAN. Selection
now takes the first non-blank CN, else the first non-blank DNS SAN, then
applies the existing character guard; a certificate with no non-blank CN or
SAN is rejected. Live E2E coverage confirms rustls/webpki accepts an
in-test-CA-signed mTLS client certificate whose Subject CN is explicitly
empty, so the blank-identity path was reachable through a real handshake,
not only by direct DER parsing. Post-fix, empty-CN-plus-DNS-SAN authenticates
under the SAN name; empty-CN-without-SAN completes the TLS handshake but is
rejected as unauthenticated before MCP handling. - API-key configuration: a blank API-key
nameis now rejected during
config validation — via bothvalidate_server_configand
McpServerConfig::validate()— naming the offending key index. The check is
exposed asAuthConfig::validate_api_key_names, so it can also be called
directly when a configuration is assembled programmatically. - API-key hot reload: added
ReloadHandle::try_reload_auth_keys(fallible)
which validates before swapping; the existingreload_auth_keysnow rejects
a blank-named batch, logging the error and leaving the previously installed
keys untouched instead of silently installing colliding keys. - Bearer verification (
verify_bearer_token): the public verifier now
returnsNone(no identity) when the matched API key has a blank name. The
check runs after the constant-time match loop resolves, so the "one Argon2
verification per configured key" timing guarantee is unchanged. - OAuth claims: blank
preferred_username/sub/azp/client_id
are treated as absent so the identity-name fallback chain no longer
short-circuits into a blank name; a blanksubis stored asNone(so the
OAuth fingerprint never keys on a blank stable id) and is rejected when
require_subjectis set. Correction: an earlier draft of this change
stated OAuth was unaffected. That was wrong — OAuth carried the same
collision and is fixed here.
fingerprint()additionally carries adebug_assert!that the selected
stable id is non-blank — an in-crate breadcrumb that the first-party
producers above are validated upstream, not a runtime guard.Compatibility: API-additive — the new
ReloadHandle::try_reload_auth_keys
andAuthConfig::validate_api_key_names— with a runtime behavioural
change:reload_auth_keysnow refuses a blank-named batch and retains the
previously installed keys rather than swapping them in.cargo semver-checks
reports the API surface as additive, but this behaviour change is why the fix
ships as a minor, not a patch. - mTLS (
Fixed
-
current_identity()andcurrent_role()now treat an empty value as
absent, matchingcurrent_token()andcurrent_sub(), which already did.
Previously two of the four task-local accessors returnedSome("")where the
other two returnedNone.The asymmetry defeated a reasonable guard.
current_sub().or_else(current_identity)
is a natural way to resolve a stable per-user key:current_sub()correctly
yieldedNone, evaluation fell through tocurrent_identity(), and the
Some("")it returned satisfied the caller'sok_or_else. A downstream
consumer was using that value as a per-user OAuth token-store key, so
identity-less callers collapsed into one shared bucket. The call site looked
correctly guarded.Reachable through an API key whose
nameis empty paired with a non-empty
role:ApiKeyEntry::newdoes not validatename, and the RBAC middleware
gates task-local installation only on the role being non-empty.This is a behaviour change. Any caller receiving
Some("")today will now
receiveNone. In this crate the change can only narrow: no internal path
treatsNoneas more permissive. Downstream code should auditunwrap_or,
unwrap_or_elseandunwrap_or_defaultfallbacks on these two accessors,
where substituting a default forNonecould widen behaviour.Scope. This normalises the accessors only. A configured
AuthIdentity
may still carry an emptyname, which remains significant for session-binding
fingerprints, admin summaries and audit logs.Reported by a downstream consumer running per-user OAuth across ~145
product-scoped tools.
Added
- Added opt-in
McpServerConfig::with_event_storefor supplying an external
rmcpEventStore. Omitting it leaves behaviour unchanged:Last-Event-ID
resume already worked best-effort in-process for live legacy sessions whose
events remained in rmcp's bounded channel cache. A configured store adds
durable, cross-instance, and stateless replay. Implementations own globally
unique event IDs and stream isolation, because rmcp calls
replay_events_afterwith only the last event ID.