# Security Policy
## Supported versions
| 1.6.x | ✅ |
| < 1.6 | ❌ |
We support the latest minor release for security fixes. Patch releases
(`x.y.Z`) are issued for the supported line only.
## Reporting a vulnerability
**Please do not report security issues through public GitHub/GitLab
issues.**
Instead, use **GitHub Security Advisories** (private vulnerability
reporting) on the public repository:
<https://github.com/andrico21/rmcp-server-kit/security/advisories/new>
Include:
- A description of the vulnerability and its impact.
- Reproduction steps (a minimal code sample if possible).
- The commit hash or release version affected.
- Any proof-of-concept exploit code.
We aim to acknowledge reports within **3 business days**, provide an
initial assessment within **7 days**, and issue a fix or mitigation plan
within **30 days** for confirmed high-severity issues.
## What counts as a vulnerability
- Authentication or authorization bypass in `auth` / `rbac` / `oauth`.
- Remote crash / denial of service triggered by a well-formed request.
- Information disclosure through error messages, logs, or admin
endpoints.
- TLS / mTLS misconfiguration that weakens transport security below
the documented baseline.
- Any issue in the OWASP Top 10 categories applicable to a server
library.
- Bypass of the trusted-forwarder client-IP resolution (e.g. a header
shape that makes a non-proxied request resolve to an
attacker-controlled IP). The trust model: forwarding headers are
consulted **only** when the direct peer is inside the operator's
`trusted_proxies` CIDRs, resolution walks the chain
rightmost-untrusted, and every ambiguous input falls back to the
direct peer. See the "Trusted-forwarder mode" section of
[`docs/GUIDE.md`](docs/GUIDE.md) for the full model.
## MCP session identity binding
Network transports bind rmcp session IDs to the authenticated identity by
default. On `initialize`, the raw rmcp `Mcp-Session-Id` is returned to the
client as a stateless signed wrapper; on later requests the wrapper is
verified against the current `AuthIdentity` and rewritten back to the raw ID
before rmcp sees it. This prevents CWE-384 session fixation/replay where a
session opened by one authenticated caller is reused by another caller that
has its own valid credentials.
The protection is controlled by `McpServerConfig::with_session_binding` and
TOML `server.session_binding` (default `true`). Setting it to `false` is an
escape hatch for a trusted gateway that deliberately re-authenticates each
request under different labels. It also reinstates the CWE-384 risk: any leaked
raw rmcp session ID can again be replayed by another authenticated identity.
Multi-replica deployments that configure `McpServerConfig::with_session_store`
must also configure a shared `server.session_binding_secret` (or the
`RMCP_SERVER_KIT__SERVER__SESSION_BINDING_SECRET_FILE` environment variable).
This shared secret is authorization-relevant: leaking it allows forging bound
session tokens for any identity fingerprint and raw session ID known to the
attacker. Treat it like an API-signing key: generate at least 32 random bytes,
store it in a secret manager, mount it with least privilege, and rotate it with
the expectation that all active bound sessions become invalid.
### Scope: sessions only
Session binding covers the `Mcp-Session-Id` header and **nothing else**. It does
not protect MCP task IDs (see below), MRTR `requestState`, or any other
long-lived identifier a handler hands to a client. Each such identifier needs
its own binding.
In MCP **2026-07-28 stateless mode** there is no session ID at all, so session
binding is inert for those clients. That is safe, not a downgrade: authentication
is evaluated per request from the `Authorization` header or the mTLS peer
certificate, and RBAC is evaluated per request against the current message body.
Neither depends on session state. Operators should simply not assume session
binding is doing work for stateless clients.
## MCP task identity binding
MCP tasks (SEP-2663) hand the client a long-lived `taskId` that is later
presented to `tasks/get`, `tasks/update`, and `tasks/cancel`. Upstream `rmcp`
resolves those calls **by task ID alone**, and this crate's RBAC layer inspects
only `tools/call`. Without binding, any *authenticated* identity holding another
identity's task ID can read, update, or cancel that task -- the same
leaked-identifier-as-bearer-capability problem session binding solves.
When enabled, the task ID returned to the client is a signed wrapper bound to
the authenticated identity, verified and rewritten back to the raw ID before the
consumer's handler sees it. A wrapper that fails verification -- malformed, not
wrapped, signed for a different identity, or signed under a rotated secret --
is rejected with exactly the error `rmcp` returns for a genuinely unknown task,
so the check cannot be used to probe whether another identity's task exists.
The protection is controlled by `McpServerConfig::with_task_binding` and TOML
`server.task_binding` (default `false`). It is **off by default** because
enabling it changes the wire format of `taskId` values.
- It reuses `server.session_binding_secret`. The two bindings are
domain-separated, so a session token can never verify as a task token or vice
versa. Rotating that secret invalidates both active bound sessions **and**
outstanding external task IDs.
- Multi-replica deployments must configure the same shared secret on every
replica, exactly as for session binding. A process-random secret invalidates
outstanding external task IDs on restart.
- **Ownership contract:** handlers own *raw* task IDs; `rmcp-server-kit` owns the
external wrapped ID. A consumer that persists the client-visible ID as its own
key will break when this is enabled.
- With authentication disabled there is no identity to bind to, so the feature
degrades to a no-op rather than failing requests.
- Current `rmcp` 3.2.0 does not route SEP-2663 `notifications/tasks` through
`subscriptions/listen`; clients observe task state by polling `tasks/get`,
which is wrapped. If a future `rmcp` release adds task-status subscriptions,
`rmcp-server-kit` must wrap the `task.task_id` inside each
`TaskStatusNotificationParams` before exposing that release. The test
`task_status_notifications_remain_unroutable_until_binding_is_added` fails
when that upstream change lands.
## Certificate revocation
> ✅ **rmcp-server-kit performs CDP-driven CRL revocation
> checking for client certificates by default whenever `[mtls]` is
> configured.** OCSP is **not** implemented.
### How CRL checking works
When mTLS is enabled, rmcp-server-kit:
1. At startup, scans the configured CA chain for the X.509 **CRL Distribution
Points** (CDP) extension and fetches each referenced CRL via HTTP(S),
bounded by a 10-second total bootstrap deadline.
2. On each new client certificate observed during a TLS handshake, lazily
discovers any additional CDP URLs the leaf or intermediates point at and
schedules them for fetch.
3. Caches every CRL in memory keyed by URL and refreshes it before
`nextUpdate` (clamped to `[10 min, 24 h]`) on a background task.
4. Hot-swaps the underlying `rustls::ClientCertVerifier` via `ArcSwap` once
new CRLs land, so handshakes always check the freshest revocation data
without dropping in-flight connections.
5. **Fails closed by default** (since 3.9): a certificate advertising CRL
distribution points is rejected when *every* relevant CDP is uncached and
unfetchable, per RFC 5280 §6.3. Denial requires all relevant CDPs to be
unavailable, not merely one, so blocking a single mirror cannot be used to
deny service. Expired CRLs are not trusted when
`crl_enforce_expiration = true` (the default); webpki rejects them at
`nextUpdate`. Operators who need the previous fail-open behaviour can set
`crl_deny_on_unavailable = false`, accepting that a revoked certificate is
then admitted whenever its CRL is unreachable.
`ReloadHandle::refresh_crls()` forces an immediate refresh of every cached
CRL - useful from an admin endpoint or a cron-driven probe.
### Configuration (TOML)
```toml
[mtls]
ca_cert_path = "/etc/certs/clients-ca.pem"
# CRL fields (all defaults shown)
crl_enabled = true # set false to disable revocation entirely
crl_deny_on_unavailable = true # fail-closed by default (RFC 5280 6.3); set false to fail open
crl_allow_http = true # allow http:// CDP URLs (CRLs are signed by the CA, so plain HTTP is acceptable)
crl_end_entity_only = false # check the full chain, not just the leaf
crl_enforce_expiration = true # reject CRLs whose nextUpdate is in the past
crl_fetch_timeout = "30s" # per-fetch HTTP timeout
crl_retry_retention = "24h" # keep failed-refresh entries for retry only; never stale use
# crl_stale_grace = "24h" # deprecated alias for crl_retry_retention
# crl_refresh_interval = "1h" # override the auto interval derived from nextUpdate
```
### Limitations
- **OCSP is not implemented.** If your PKI distributes revocation only via
OCSP (no CDP), CRL checking will not protect you. Mitigations below
still apply.
- **Caches are per-process and in-memory.** Restarting the process drops
the cache; bootstrap re-fetches everything within the 10 s deadline.
- **CDP URLs are honoured after SSRF normalisation, not rewritten.**
rmcp-server-kit does not proxy or pin CDP URLs, but it does enforce a
scheme allowlist, reject userinfo, and refuse private/loopback/link-local/
cloud-metadata IP literals before issuing the fetch (see
[CRL fetch SSRF hardening](#crl-fetch-ssrf-hardening) below).
Operators must still ensure their issuing CA's CDP host is reachable
from the server's network.
- **Default is fail-closed** (since 3.9). A certificate whose revocation
status cannot be determined is rejected, per RFC 5280 6.3. Denial requires
*every* relevant CDP to be unavailable, so blocking a single mirror cannot
be used to deny service. Set `crl_deny_on_unavailable = false` to restore
the previous fail-open behaviour, which prioritises availability over
confidentiality and accepts a revoked certificate whenever its CRL is
unreachable.
### CRL fetch SSRF hardening
CRL Distribution Point URLs are extracted from X.509 extensions on
attacker-influenceable client certificates, so the CRL fetcher is treated
as a hostile-input network call. Before any HTTP request is issued,
`src/mtls_revocation.rs::ssrf_guard` rejects URLs that:
- Use a scheme other than `http://` or `https://` (`ftp://`, `file://`,
`gopher://`, `data:`, `dict://`, etc. are all denied).
- Carry RFC 3986 userinfo (`user:pass@host`).
- Resolve (after DNS) to a private/loopback/link-local/multicast/
unspecified/broadcast IPv4 or IPv6 address, including the cloud
metadata endpoints `169.254.169.254` and `fd00:ec2::254`, IPv4-mapped
IPv6 `::ffff:0:0/96`, IPv4-compatible IPv6, IPv6 unique-local
`fc00::/7`, IPv6 link-local `fe80::/10`, and the IPv6 loopback `::1`.
In addition the fetcher applies four bounded-resource caps to limit
SSRF/DoS amplification even if a CRL host is reachable:
| `crl_max_concurrent_fetches` | `4` | Global cap on parallel CRL fetches across all hosts (per-host concurrency is hard-capped at 1). |
| `crl_max_response_bytes` | `5 MiB` | Body size cap; streams aborted mid-response when exceeded. |
| `crl_discovery_rate_per_min` | `60` | Process-global rate limit on *new* CDP URLs admitted into the fetch pipeline. |
| `crl_fetch_timeout` | `30 s` | Per-fetch HTTP timeout. |
| `crl_max_host_semaphores` | `1024` | Caps the number of unique CDP hosts tracked for per-host concurrency gating. At the cap, idle entries (no in-flight fetch) are evicted on demand, so the cap only rejects genuinely concurrent fetch floods - it is never a permanent lockout. |
| `crl_max_seen_urls` | `4096` | Caps the URL-deduplication map to prevent unbounded memory growth from discovery. |
| `crl_max_cache_entries` | `1024` | Caps the number of parsed CRLs held in memory. |
The fetcher also disables HTTP redirects entirely for CRL traffic - a
CRL is signed by the issuing CA, so blindly following a redirect to an
operator-unintended host has no security benefit.
Discovery URLs containing IP literals are normalized
(rejecting octal/hex/percent-encoded obfuscation) before the SSRF check.
#### IPv6 transition prefixes
The IP range guard shared by the CRL and OAuth fetchers also classifies
IPv6 transition-mechanism prefixes:
- **NAT64 well-known prefix `64:ff9b::/96` (RFC 6052)** and
**6to4 `2002::/16` (RFC 3056)**: the IPv4 address embedded in the
prefix is extracted and checked against the full IPv4 block list. The
address is rejected when the embedded target is itself blocked (e.g.
`64:ff9b::10.0.0.1` would reach internal RFC 1918 space through a NAT64
gateway), and permitted when it embeds a public IPv4 address - on
DNS64/NAT64-only egress networks every public host maps into the NAT64
prefix, so blocking it wholesale would break all outbound fetches.
- **Teredo `2001::/32` (RFC 4380)**: blocked outright. The tunneling
protocol is obsolete, its embedded client address is XOR-obfuscated and
attacker-chosen, and no legitimate JWKS/CRL endpoint is reachable only
via Teredo.
### CRL discovery under adversarial load
CDP URLs are extracted from client certificates **before** chain
validation. This ordering is a deliberate, load-bearing invariant: with
`crl_deny_on_unavailable = true` the verifier must be able to fail
closed on a never-fetched CDP, which requires discovering the CDP before
delegating to the inner verifier. No HTTP happens on the handshake path -
discovery only enqueues onto a bounded, rate-limited channel, and the
actual fetch runs on a background task behind the full SSRF guard.
The residual cost of that ordering is a bounded griefing window: an
**unauthenticated** client can present throwaway certificates carrying
unique CDP URLs and consume the process-global discovery budget
(`crl_discovery_rate_per_min`) and `crl_max_seen_urls` slots. Memory
stays bounded - the caps exist precisely for this - but discovery of
*new legitimate* CDP URLs can be starved while the spray is in progress,
which under `crl_deny_on_unavailable = true` fails those handshakes
closed. Per-source-IP discovery budgeting is not possible at this layer:
`rustls`'s `ClientCertVerifier` callback has no access to the peer
address.
Operator guidance:
- Alert on `discovery_rate_limited` WARN log lines - they are the
observable signature of a discovery spray (or of an undersized budget).
- Size `crl_max_seen_urls` and `crl_max_cache_entries` to comfortably
exceed your CA estate's real CDP count, especially with
`crl_deny_on_unavailable = true`: at the cache cap the **newest** entry
is rejected (never an existing one - LRU eviction would let an attacker
evict the legitimate warm set by spamming throwaway CDP URLs), so a
full cache means newly discovered legitimate CDPs cannot enter until
capacity frees up.
- Pre-seed the cache via the startup bootstrap: CDPs present in the
configured CA chain are fetched before the listener starts and are
immune to runtime discovery contention.
### Out-of-band CRL cache mutation
`CrlSet::cache` is a `pub` field (deprecated since 3.9, private in 4.0).
Writing through it bypasses the atomic commit path, which publishes the
rustls verifier, the `cached_urls` coverage hint, and a per-entry identity
index together as one immutable snapshot. A same-key replacement or a direct
removal performed out of band would otherwise leave the coverage hint
claiming revocation coverage the live verifier cannot enforce.
Since 3.9 the synchronous mTLS precheck compares the live cache against the
committed identity index before trusting `cached_urls`. A relevant CDP URL
whose entry is missing, whose identity is missing, or whose identity differs
**fails the handshake closed** and emits a throttled
`crl_cache_out_of_band_mutation` WARN, distinct from the ordinary
unavailable-CRL denial.
**What this does and does not guarantee.** This detects **API misuse by
non-adversarial code** - the hazard the `pub` field creates. It is **not** a
cryptographic integrity check against a same-process adversary, and must not
be relied on as one: code able to take the cache write lock is already inside
the trust boundary. The identity is a constant-cost tuple (DER address and
length, a 32-byte head and tail sample, the timestamps, and the source URL);
a caller that deliberately drops an entry and reallocates a replacement at the
same address with the same length and samples would not be detected.
Comparing a cryptographic digest of the DER instead was implemented and
measured, then rejected: at the 5 MiB `crl_max_response_bytes` default it cost
**56.3 ms p95 per relevant cached CRL** (900.9 ms for a single handshake
advertising 16 cached CDP URLs), scaling linearly in both CRL size and an
attacker-chosen URL count, on the **unauthenticated** handshake path. That
trades a local misuse tripwire for a remote CPU-amplification vulnerability.
The shipped identity comparison is independent of CRL size - measured at
7.1 us (64 KiB) versus 20.1 us (5 MiB) at 16 URLs - and
`benches/crl_precheck.rs` gates that invariance so a full-DER scan cannot be
reintroduced unnoticed.
### Per-handshake CDP URL cap
A client certificate advertising more than **64** distinct CDP URLs is
rejected as malformed, before CDP discovery and **in both fail-open and
fail-closed modes**, with a throttled `crl_cdp_url_cap_exceeded` WARN. Every
step of CDP handling is linear in this peer-chosen count, so an unbounded
count is an amplification primitive.
There is deliberately **no opt-out**. Setting `crl_deny_on_unavailable = false`
opts out of *revocation-unavailability* denials; it does not opt out of
malformed-certificate rejection, and the same amplification is paid in
fail-open mode.
RFC 5280 [4.2.1.13](https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.13)
treats multiple URIs inside one `DistributionPoint` as alternative ways to
obtain *the same* CRL, so a conforming certificate needs only a handful.
### Why `crl_max_response_bytes` stays at 5 MiB
Lowering the fetch cap is **not** a legitimate way to bound revocation-checking
cost, and 3.9 deliberately does not do it.
- **RFC 5280 is silent on CRL size.** It specifies no maximum CRL size, no
recommended byte size, and no client fetch cap
([3.3](https://www.rfc-editor.org/rfc/rfc5280#section-3.3),
[5](https://www.rfc-editor.org/rfc/rfc5280#section-5),
[6.3.3](https://www.rfc-editor.org/rfc/rfc5280#section-6.3.3)). Neither do
RFC 2585, RFC 4387, RFC 6960, or the CA/Browser Forum Baseline Requirements.
Any client-side cap is purely an implementation defence.
- **The standard bounds size by partitioning, and clients need not support it.**
`issuingDistributionPoint`
([5.2.5](https://www.rfc-editor.org/rfc/rfc5280#section-5.2.5)) and delta CRLs
([5.2.4](https://www.rfc-editor.org/rfc/rfc5280#section-5.2.4)) are the
RFC's answers to large CRLs, but conforming implementations are explicitly
**not required** to support either, so neither can be assumed to bound what a
server must be able to fetch.
- **Real CRLs already exceed 5 MiB.** Public-CA CRLs of ~9.5 MB and ~12 MB have
been measured in the wild, and Let's Encrypt's sharding design was sized for
~70 MB per shard worst case across 128 shards.
- **Comparable implementations cap far higher.** OpenSSL's
`OSSL_HTTP_DEFAULT_MAX_CRL_LEN` is 32 MiB and OpenJDK's
`com.sun.security.crl.maxSize` defaults to 20 MiB; rustls/rustls-webpki does
no fetching at all. The only numeric size guidance found in any root program
is CA-side: Microsoft asks that a CRL be under 10 MB when the end-entity
certificate carries no OCSP URL.
5 MiB is therefore already the most conservative cap among implementations that
fetch, and already below deployed real-world CRLs. Operators serving a CA estate
with larger CRLs should **raise** it. Operators facing handshake floods should
prefer `crl_deny_on_unavailable = false` (accepting the documented revocation
risk) over lowering the cap, which silently disables revocation for any CA whose
CRL exceeds it.
### OAuth SSRF hardening
When the optional `oauth` feature is enabled, the JWKS fetcher and the
shared `OauthHttpClient` (used for token exchange, introspection, and
revocation) enforce the same per-hop SSRF guard as the CRL fetcher.
In addition to the per-hop DNS/private-IP guard, the OAuth subsystem
applies three resource-exhaustion caps:
| `max_jwks_keys` | `256` | Caps the number of public keys parsed from a single JWKS document; fail-closed on overflow. |
| `reqwest` default | `10` | Hard limit on the number of HTTP redirects followed during a fetch (not user-tunable). |
| `OauthHttpClient` timeout | `30 s` | Total timeout for an OAuth-bound HTTP request (default). |
Furthermore, `check_oauth_url` (applied at config-construction time
and redirect time) rejects URLs that:
- Carry RFC 3986 userinfo (`https://user:pass@host/`).
- Use an IP literal in the host position (`https://127.0.0.1/`).
These hardening measures ensure that the operator-trusted configuration
model remains robust against hostile or compromised Identity Providers.
#### Operator allowlist
Some deployments terminate OAuth/JWKS at an in-cluster IdP whose
hostname legitimately resolves into private (RFC 1918), loopback, CGNAT,
or unique-local space (for example a Keycloak `Service` ClusterIP). The
default fail-closed policy described above blocks those targets.
`OAuthConfig::ssrf_allowlist` is the **opt-in operator escape hatch**.
It accepts:
- `hosts`: case-insensitive exact-match DNS hostnames.
- `cidrs`: IPv4 or IPv6 CIDR blocks whose addresses the fetcher is
permitted to reach even when otherwise classified as blocked.
The allowlist is checked **after** the IP-block classifier runs, and
**only** for non-cloud-metadata reasons. Concretely:
1. Cloud-metadata addresses are unbypassable. AWS IPv4
(`169.254.169.254`), AWS IPv6 (`fd00:ec2::254`), GCP IPv6
(`fd20:ce::254`), and the Alibaba/Tencent IPv4 metadata address
(`100.100.100.200`) are classified as `cloud_metadata` *before* the
generic `unique_local` / `cgnat` / `link_local` buckets, so listing
`fd00::/8`, `100.64.0.0/10`, or `169.254.0.0/16` in `cidrs` never
re-allows the metadata addresses themselves.
2. The empty (default) allowlist preserves the pre-1.4.0 behaviour
verbatim, including the exact wording of the "OAuth target resolved
to blocked IP" error message, so existing operator runbooks and
alerting continue to work unchanged.
3. `https -> http` redirect downgrades remain rejected unconditionally.
`allow_http_oauth_urls = true` controls whether the **initial**
request URL may be plain HTTP; the allowlist does not weaken this
policy.
4. Misconfiguration (literal IPs in `hosts`, ports/paths/userinfo in
`hosts`, malformed CIDRs, `/0`, IPv4-mapped IPv6 CIDRs, zone IDs,
non-zero host bits) is rejected at startup -- the server refuses to
come up, rather than fail-open.
5. When the allowlist is non-empty, `OAuthConfig::validate` emits a
`tracing::warn!` naming the host and CIDR counts so the elevated
trust posture is auditable in the deployment's log pipeline.
Operational guidance:
- Prefer `cidrs` over `hosts` when the IdP is reached via a stable IP
range. Hostname allowlists trust DNS to remain truthful; CIDR
allowlists do not.
- Keep the allowlist as narrow as possible. `10.0.0.0/8` is much
weaker than the actual `/24` of the IdP's `Service` subnet.
- Audit the `oauth.ssrf_allowlist is configured` warn-level log line on
every restart and on every config reload.
### OAuth HTTPS enforcement
When the optional `oauth` feature is enabled, `OauthHttpClient`
(`src/oauth.rs`) installs a redirect policy that:
- Rejects HTTPS → HTTP downgrades unconditionally.
- Allows HTTP → HTTP only when the operator has set
`oauth.allow_http_oauth_urls = true` (off by default; intended for
local development against a non-TLS IdP).
- Caps redirect hops to a small constant.
Prefer `OauthHttpClient::with_config(&OAuthConfig)` so that this policy
and the configured CA bundle are wired consistently for every
OAuth-bound HTTPS call (JWKS, discovery, token exchange, the optional
`/authorize`/`/token`/`/register`/`/introspect`/`/revoke` proxy
upstreams).
#### Trust boundary on OAuth endpoint URLs
The `oauth.issuer`, `oauth.jwks_uri`, and other OAuth/OIDC endpoint
URLs are treated as **operator-trusted configuration**, not as
attacker-supplied input. OAuth URL hardening operates in three layers:
1. **Validate-time literal-IP rejection.** `OAuthConfig::validate`
rejects userinfo and ALL literal IP hosts across the six configured
URL fields (operators must use DNS hostnames). This is the primary
trust anchor for operator-supplied URLs.
2. **Async post-DNS screening on the initial request.** Both
`OauthHttpClient` and `JwksCache` resolve the target hostname and
classify every returned IP against the SSRF range guard *before*
issuing the request, rejecting targets in private, loopback,
link-local, multicast, broadcast, unspecified, CGNAT, or
cloud-metadata ranges. The opt-in `OAuthConfig::ssrf_allowlist`
relaxes this for in-cluster IdPs but cannot bypass cloud-metadata
addresses (see the "Operator allowlist" subsection above).
3. **Sync per-hop guard on redirects.** A per-hop SSRF range guard runs
inside both client redirect closures using literal-IP classification
(no extra DNS), rejecting cross-hop pivots into blocked ranges.
`https -> http` redirect downgrades are always rejected; `http -> http`
is permitted only when `allow_http_oauth_urls = true`.
Implications:
- Do **not** allow tenants or end-users to influence
`oauth.issuer` / `oauth.jwks_uri` / discovery URLs at runtime.
- A compromised IdP cannot reach internal hosts behind the SSRF guard,
but can still trigger HTTPS GETs to any public host reachable from
the deployment. Combine with strict egress firewalling for
high-assurance environments.
- "Key stuffing" attacks where a hostile IdP returns thousands of JWKS
keys to slow down validation are blocked by the `max_jwks_keys` cap
(default 256).
### Defence-in-depth (still recommended)
Even with CRL enabled, the original mitigations remain best practice:
1. **Short-lived certificates (≤24h)** - bounds exposure regardless of CRL
propagation latency.
- [cert-manager](https://cert-manager.io/) `Certificate.spec.duration: 24h`, `renewBefore: 8h`.
- [HashiCorp Vault PKI](https://developer.hashicorp.com/vault/docs/secrets/pki) `max_ttl=24h` with agent-driven renewal.
- [Smallstep `step-ca`](https://smallstep.com/docs/step-ca/) with the autorenewal daemon.
2. **CA rotation on compromise** - for longer-lived certs you can still
rotate the issuing CA and reload via `ReloadHandle::reload_*` for a
zero-downtime swap of trust roots.
3. **Network-layer revocation** - block compromised peers at the service
mesh / load balancer / firewall for sub-second propagation.
### What "point-in-time mTLS" still means
CRL checking happens at handshake time. After a connection is established,
the session remains trusted for its lifetime regardless of any subsequent
revocation event. **A long-lived mTLS session with a certificate that is
revoked *after* the handshake will continue to be honoured until the
connection is closed by either side.** Combine short-lived sessions with
short-lived certs for the strongest guarantees.
### Threat model addendum
- A stolen private key is valid until either (a) the next CRL publication
marks it revoked **and** rmcp-server-kit's cache refreshes, or (b) the
certificate's `notAfter` passes - whichever comes first. ≤24 h cert
lifetimes still bound this exposure even when CRL fetching fails.
- An evicted operator's certificate becomes invalid as soon as the
issuing CA publishes the updated CRL and rmcp-server-kit refreshes it
(≤ `nextUpdate` clamped to 24 h, or immediately via
`ReloadHandle::refresh_crls()`).
- OCSP is not implemented; if your PKI publishes only OCSP, treat
revocation as unsupported and apply the defence-in-depth mitigations
above.
## Coordinated disclosure
Once a fix is released, we will:
1. Publish a `RUSTSEC` advisory if `rustsec/advisory-db` accepts it.
2. Tag the release `X.Y.Z` (no `v` prefix) with a `[SECURITY]`
changelog entry.
3. Credit the reporter (unless they request anonymity).