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

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

```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](#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.

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

