Skip to content

3.9.0

Choose a tag to compare

@github-actions github-actions released this 04 Sep 14:16
· 265 commits to main since this release

Security

  • Closed a cross-principal session-binding collision (CWE-384) caused by a
    blank AuthIdentity stable 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 name is now rejected during
      config validation — via both validate_server_config and
      McpServerConfig::validate() — naming the offending key index. The check is
      exposed as AuthConfig::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 existing reload_auth_keys now 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
      returns None (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 blank sub is stored as None (so the
      OAuth fingerprint never keys on a blank stable id) and is rejected when
      require_subject is 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 a debug_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
    and AuthConfig::validate_api_key_names — with a runtime behavioural
    change
    : reload_auth_keys now 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.

Fixed

  • current_identity() and current_role() now treat an empty value as
    absent
    , matching current_token() and current_sub(), which already did.
    Previously two of the four task-local accessors returned Some("") where the
    other two returned None.

    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
    yielded None, evaluation fell through to current_identity(), and the
    Some("") it returned satisfied the caller's ok_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 name is empty paired with a non-empty
    role: ApiKeyEntry::new does not validate name, 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
    receive None. In this crate the change can only narrow: no internal path
    treats None as more permissive. Downstream code should audit unwrap_or,
    unwrap_or_else and unwrap_or_default fallbacks on these two accessors,
    where substituting a default for None could widen behaviour.

    Scope. This normalises the accessors only. A configured AuthIdentity
    may still carry an empty name, 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_store for supplying an external
    rmcp EventStore. 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_after with only the last event ID.