Skip to content

Security: andrico21/rmcp-server-kit

Security

SECURITY.md

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 for the full model.

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 open by default: if a CRL cannot be fetched or has expired beyond the configured grace period, the handshake is still allowed and a WARN log is emitted. Operators who require fail-closed semantics can set crl_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.

Configuration (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  = 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

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 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 = true if your threat model inverts that trade-off.

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.

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.
  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).

There aren't any published security advisories