| 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.
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.
- 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_proxiesCIDRs, resolution walks the chain rightmost-untrusted, and every ambiguous input falls back to the direct peer. See the "Trusted-forwarder mode" section ofdocs/GUIDE.mdfor the full model.
✅ rmcp-server-kit performs CDP-driven CRL revocation checking for client certificates by default whenever
[mtls]is configured. OCSP is not implemented.
When mTLS is enabled, rmcp-server-kit:
- 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.
- 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.
- Caches every CRL in memory keyed by URL and refreshes it before
nextUpdate(clamped to[10 min, 24 h]) on a background task. - Hot-swaps the underlying
rustls::ClientCertVerifierviaArcSwaponce new CRLs land, so handshakes always check the freshest revocation data without dropping in-flight connections. - Fails open by default: if a CRL cannot be fetched or has expired
beyond the configured grace period, the handshake is still allowed and
a
WARNlog is emitted. Operators who require fail-closed semantics can setcrl_deny_on_unavailable = true.
ReloadHandle::refresh_crls() forces an immediate refresh of every cached
CRL — useful from an admin endpoint or a cron-driven probe.
[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 = false # fail-open by default; set true for fail-closed
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 (subject to crl_stale_grace)
crl_fetch_timeout = "30s" # per-fetch HTTP timeout
crl_stale_grace = "24h" # how long an expired CRL can still be trusted while we keep retrying
# crl_refresh_interval = "1h" # override the auto interval derived from nextUpdate- 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 below). Operators must still ensure their issuing CA's CDP host is reachable from the server's network.
- Default is fail-open. This protects availability over confidentiality;
set
crl_deny_on_unavailable = trueif your threat model inverts that trade-off.
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://orhttps://(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.254andfd00:ec2::254, IPv4-mapped IPv6::ffff:0:0/96, IPv4-compatible IPv6, IPv6 unique-localfc00::/7, IPv6 link-localfe80::/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.
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 6to42002::/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.1would 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.
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_limitedWARN log lines — they are the observable signature of a discovery spray (or of an undersized budget). - Size
crl_max_seen_urlsandcrl_max_cache_entriesto comfortably exceed your CA estate's real CDP count, especially withcrl_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.
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.
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:
- 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 ascloud_metadatabefore the genericunique_local/cgnat/link_localbuckets, so listingfd00::/8,100.64.0.0/10, or169.254.0.0/16incidrsnever re-allows the metadata addresses themselves. - 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.
https -> httpredirect downgrades remain rejected unconditionally.allow_http_oauth_urls = truecontrols whether the initial request URL may be plain HTTP; the allowlist does not weaken this policy.- Misconfiguration (literal IPs in
hosts, ports/paths/userinfo inhosts, 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. - When the allowlist is non-empty,
OAuthConfig::validateemits atracing::warn!naming the host and CIDR counts so the elevated trust posture is auditable in the deployment's log pipeline.
Operational guidance:
- Prefer
cidrsoverhostswhen 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/8is much weaker than the actual/24of the IdP'sServicesubnet. - Audit the
oauth.ssrf_allowlist is configuredwarn-level log line on every restart and on every config reload.
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).
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:
- Validate-time literal-IP rejection.
OAuthConfig::validaterejects 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. - Async post-DNS screening on the initial request. Both
OauthHttpClientandJwksCacheresolve 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-inOAuthConfig::ssrf_allowlistrelaxes this for in-cluster IdPs but cannot bypass cloud-metadata addresses (see the "Operator allowlist" subsection above). - 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 -> httpredirect downgrades are always rejected;http -> httpis permitted only whenallow_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_keyscap (default 256).
Even with CRL enabled, the original mitigations remain best practice:
- Short-lived certificates (≤24h) — bounds exposure regardless of CRL
propagation latency.
- cert-manager
Certificate.spec.duration: 24h,renewBefore: 8h. - HashiCorp Vault PKI
max_ttl=24hwith agent-driven renewal. - Smallstep
step-cawith the autorenewal daemon.
- cert-manager
- 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. - Network-layer revocation — block compromised peers at the service mesh / load balancer / firewall for sub-second propagation.
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.
- 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
notAfterpasses — 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
(≤
nextUpdateclamped to 24 h, or immediately viaReloadHandle::refresh_crls()). - OCSP is not implemented; if your PKI publishes only OCSP, treat revocation as unsupported and apply the defence-in-depth mitigations above.
Once a fix is released, we will:
- Publish a
RUSTSECadvisory ifrustsec/advisory-dbaccepts it. - Tag the release
X.Y.Z(novprefix) with a[SECURITY]changelog entry. - Credit the reporter (unless they request anonymity).