rmcp-server-kit 3.8.3

Reusable MCP server framework with auth, RBAC, and Streamable HTTP transport (built on the rmcp SDK)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
# Security Policy

## Supported versions

| Version  | Supported |
|----------|-----------|
| 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.

## 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:

| Knob                            | Default       | Purpose                                                                                       |
|---------------------------------|---------------|-----------------------------------------------------------------------------------------------|
| `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:

| Knob                            | Default       | Purpose                                                                                       |
|---------------------------------|---------------|-----------------------------------------------------------------------------------------------|
| `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).