#![allow(clippy::expect_used, reason = "tests")]
#![allow(clippy::unwrap_used, reason = "tests")]
#![allow(clippy::panic, reason = "tests")]
#![allow(clippy::indexing_slicing, reason = "tests")]
use std::time::Duration;
use rcgen::{
BasicConstraints, CertificateParams, CertificateRevocationListParams, CertifiedIssuer,
CrlDistributionPoint, DnType, IsCa, KeyIdMethod, KeyPair, KeyUsagePurpose, RevocationReason,
RevokedCertParams, SerialNumber, date_time_ymd,
};
use rmcp_server_kit::mtls_revocation::extract_cdp_urls;
use rustls::pki_types::CertificateRevocationListDer;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
fn build_ca() -> CertifiedIssuer<'static, KeyPair> {
let mut params = CertificateParams::new(Vec::<String>::new()).expect("ca params");
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
params
.distinguished_name
.push(DnType::CommonName, "ssrf-ca");
let key = KeyPair::generate().expect("ca key");
CertifiedIssuer::self_signed(params, key).expect("ca self-signed")
}
fn build_cert_with_cdp_urls(uris: Vec<String>) -> rustls::pki_types::CertificateDer<'static> {
let ca = build_ca();
let mut params = CertificateParams::new(vec!["localhost".to_owned()]).expect("params");
params.serial_number = Some(SerialNumber::from(42_u64));
params
.distinguished_name
.push(DnType::CommonName, "ssrf-leaf");
params.crl_distribution_points = vec![CrlDistributionPoint { uris }];
let key = KeyPair::generate().expect("leaf key");
let cert = params.signed_by(&key, &ca).expect("leaf signed");
cert.der().clone()
}
fn build_test_crl_der() -> Vec<u8> {
let ca = build_ca();
let der: CertificateRevocationListDer<'static> = CertificateRevocationListParams {
this_update: date_time_ymd(2026, 1, 1),
next_update: date_time_ymd(2027, 1, 1),
crl_number: SerialNumber::from(1_u64),
issuing_distribution_point: None,
revoked_certs: vec![RevokedCertParams {
serial_number: SerialNumber::from(42_u64),
revocation_time: date_time_ymd(2026, 1, 2),
reason_code: Some(RevocationReason::KeyCompromise),
invalidity_date: None,
}],
key_identifier_method: KeyIdMethod::Sha256,
}
.signed_by(&ca)
.expect("signed crl")
.into();
der.as_ref().to_vec()
}
fn build_hardened_client() -> reqwest::Client {
let _ = rustls::crypto::ring::default_provider().install_default();
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.connect_timeout(Duration::from_secs(3))
.tcp_keepalive(None)
.redirect(reqwest::redirect::Policy::none())
.user_agent("rmcp-server-kit-test")
.build()
.expect("hardened client builds")
}
#[test]
fn extract_cdp_urls_drops_disallowed_schemes() {
let cert = build_cert_with_cdp_urls(vec![
"ldap://example.com/crl".to_owned(),
"file:///etc/passwd".to_owned(),
"ftp://example.com/crl.crl".to_owned(),
"https://crl.example.com/test.crl".to_owned(),
]);
let urls = extract_cdp_urls(&cert, false);
assert_eq!(urls.len(), 1, "only HTTPS should survive: {urls:?}");
assert_eq!(urls[0], "https://crl.example.com/test.crl");
}
#[test]
fn extract_cdp_urls_filters_http_when_disallowed() {
let cert = build_cert_with_cdp_urls(vec![
"http://crl.example.com/x.crl".to_owned(),
"https://crl.example.com/y.crl".to_owned(),
]);
let urls_strict = extract_cdp_urls(&cert, false);
assert_eq!(urls_strict.len(), 1);
assert!(urls_strict[0].starts_with("https://"));
let urls_lax = extract_cdp_urls(&cert, true);
assert_eq!(urls_lax.len(), 2);
}
#[test]
fn extract_cdp_urls_drops_malformed() {
let cert = build_cert_with_cdp_urls(vec![
"not-a-url".to_owned(),
":::::broken:::".to_owned(),
"https://valid.example.com/crl".to_owned(),
]);
let urls = extract_cdp_urls(&cert, false);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0], "https://valid.example.com/crl");
}
#[test]
fn extract_cdp_urls_handles_uppercase_scheme() {
let cert = build_cert_with_cdp_urls(vec!["HTTPS://crl.example.com/X.crl".to_owned()]);
let urls = extract_cdp_urls(&cert, false);
assert_eq!(urls.len(), 1);
assert!(urls[0].starts_with("https://"));
}
#[tokio::test]
async fn hardened_client_refuses_redirect() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/crl"))
.respond_with(
ResponseTemplate::new(302).insert_header("location", "http://elsewhere.invalid/x"),
)
.mount(&mock)
.await;
let client = build_hardened_client();
let url = format!("{}/crl", mock.uri());
let response = client.get(&url).send().await.expect("got 302 back");
assert_eq!(
response.status().as_u16(),
302,
"redirect must surface as 302 (not followed)"
);
assert!(
response.url().as_str().starts_with(&mock.uri()),
"response URL must remain the original (no follow): {}",
response.url()
);
}
#[tokio::test]
async fn hardened_client_surfaces_5xx_as_error() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/crl"))
.respond_with(ResponseTemplate::new(503))
.mount(&mock)
.await;
let client = build_hardened_client();
let url = format!("{}/crl", mock.uri());
let err = client
.get(&url)
.send()
.await
.expect("got 503")
.error_for_status()
.expect_err("5xx should error");
assert!(err.status().is_some_and(|s| s.as_u16() == 503));
}
#[tokio::test]
async fn body_cap_rejects_oversized_response() {
let mock = MockServer::start().await;
let big_body = vec![0u8; 8 * 1024]; Mock::given(method("GET"))
.and(path("/big.crl"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(big_body.clone()))
.mount(&mock)
.await;
let client = build_hardened_client();
let url = format!("{}/big.crl", mock.uri());
let max_bytes: u64 = 1024;
let mut response = client
.get(&url)
.send()
.await
.expect("send")
.error_for_status()
.expect("ok");
let mut body: Vec<u8> = Vec::new();
let mut hit_cap = false;
while let Some(chunk) = response.chunk().await.expect("chunk") {
let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
if body_len.saturating_add(chunk_len) > max_bytes {
hit_cap = true;
break;
}
body.extend_from_slice(&chunk);
}
assert!(hit_cap, "body cap must trip on oversized response");
assert!(
u64::try_from(body.len()).unwrap_or(u64::MAX) <= max_bytes,
"body cannot exceed cap: {} vs {max_bytes}",
body.len()
);
}
#[tokio::test]
async fn body_cap_allows_undersized_response() {
let crl_bytes = build_test_crl_der();
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/small.crl"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(crl_bytes.clone()))
.mount(&mock)
.await;
let client = build_hardened_client();
let url = format!("{}/small.crl", mock.uri());
let max_bytes: u64 = 5 * 1024 * 1024;
let mut response = client
.get(&url)
.send()
.await
.expect("send")
.error_for_status()
.expect("ok");
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = response.chunk().await.expect("chunk") {
let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
assert!(
body_len.saturating_add(chunk_len) <= max_bytes,
"should never exceed cap"
);
body.extend_from_slice(&chunk);
}
assert_eq!(body, crl_bytes);
}
#[test]
fn cert_without_cdp_returns_empty() {
let ca = build_ca();
let mut params = CertificateParams::new(vec!["localhost".to_owned()]).expect("params");
params.serial_number = Some(SerialNumber::from(7_u64));
params.distinguished_name.push(DnType::CommonName, "no-cdp");
let key = KeyPair::generate().expect("key");
let cert = params.signed_by(&key, &ca).expect("signed");
let urls = extract_cdp_urls(cert.der(), true);
assert!(urls.is_empty(), "no CDP extension means no URLs");
}
#[test]
fn malformed_cert_der_returns_empty() {
let urls = extract_cdp_urls(b"not a certificate", true);
assert!(urls.is_empty(), "garbage DER must not panic");
}