use std::time::Duration;
use futures::StreamExt;
use rmcp::transport::auth::{
OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy,
OAuthHttpRequest,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use url::Url;
use zeph_common::net::resolve_and_validate;
use crate::error::McpError;
const DEFAULT_OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024;
const MAX_OAUTH_REDIRECT_HOPS: usize = 10;
pub(crate) struct PinningOAuthHttpClient {
server_id: String,
trusted: bool,
}
fn is_body_describing_header(name: &http::HeaderName) -> bool {
*name == http::header::CONTENT_LENGTH
|| *name == http::header::CONTENT_TYPE
|| *name == http::header::CONTENT_ENCODING
}
fn is_cross_origin_sensitive_header(name: &http::HeaderName) -> bool {
*name == http::header::AUTHORIZATION
|| *name == http::header::COOKIE
|| *name == http::header::PROXY_AUTHORIZATION
|| *name == http::header::WWW_AUTHENTICATE
}
impl PinningOAuthHttpClient {
pub(crate) fn new(server_id: impl Into<String>, trusted: bool) -> Self {
Self {
server_id: server_id.into(),
trusted,
}
}
async fn build_client(
&self,
host: &str,
port: u16,
timeout: Option<Duration>,
) -> Result<reqwest::Client, OAuthHttpClientError> {
let mut builder = reqwest::Client::builder()
.timeout(timeout.unwrap_or(DEFAULT_OAUTH_HTTP_TIMEOUT))
.redirect(reqwest::redirect::Policy::none());
if self.trusted {
tracing::debug!(
server_id = %self.server_id,
host,
"oauth http client: trusted connection, skipping SSRF validation"
);
} else {
let addrs = resolve_and_validate(host, port).await.map_err(|e| {
tracing::warn!(
server_id = %self.server_id,
host,
error = %e,
"oauth http client: blocked SSRF-unsafe request target"
);
OAuthHttpClientError::new(e.to_string())
})?;
tracing::debug!(
server_id = %self.server_id,
host,
addr_count = addrs.len(),
"oauth http client: pinning request to validated addresses"
);
builder = builder.resolve_to_addrs(host, &addrs);
}
builder.build().map_err(|e| {
OAuthHttpClientError::new(format!("failed to build OAuth HTTP client: {e}"))
})
}
async fn execute_single(
&self,
request: &http::Request<Vec<u8>>,
timeout: Option<Duration>,
) -> Result<http::Response<Vec<u8>>, OAuthHttpClientError> {
let uri = request.uri();
let host = uri
.host()
.ok_or_else(|| OAuthHttpClientError::new("OAuth request URI missing host"))?
.to_owned();
let port = uri
.port_u16()
.unwrap_or(if uri.scheme_str() == Some("http") {
80
} else {
443
});
let client = self.build_client(&host, port, timeout).await?;
let mut builder = client.request(request.method().clone(), uri.to_string());
for (name, value) in request.headers() {
builder = builder.header(name, value);
}
builder = builder.body(request.body().clone());
let response = builder
.send()
.await
.map_err(|e| OAuthHttpClientError::new(e.to_string()))?;
let mut resp_builder = http::Response::builder()
.status(response.status())
.version(response.version());
for (name, value) in response.headers() {
resp_builder = resp_builder.header(name, value);
}
let mut body = Vec::new();
let mut body_stream = response.bytes_stream();
while let Some(chunk) = body_stream.next().await {
let chunk = chunk.map_err(|e| OAuthHttpClientError::new(e.to_string()))?;
if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES.saturating_sub(body.len()) {
return Err(OAuthHttpClientError::new(format!(
"OAuth HTTP response body exceeds {MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES} bytes"
)));
}
body.extend_from_slice(&chunk);
}
resp_builder
.body(body)
.map_err(|e| OAuthHttpClientError::new(e.to_string()))
}
fn next_hop_request(
prev: &http::Request<Vec<u8>>,
response: &http::Response<Vec<u8>>,
) -> Result<http::Request<Vec<u8>>, OAuthHttpClientError> {
let location = response
.headers()
.get(http::header::LOCATION)
.ok_or_else(|| OAuthHttpClientError::new("redirect response missing Location header"))?
.to_str()
.map_err(|e| OAuthHttpClientError::new(format!("invalid Location header: {e}")))?;
let base = Url::parse(&prev.uri().to_string())
.map_err(|e| OAuthHttpClientError::new(format!("invalid request URI: {e}")))?;
let next_url = base.join(location).map_err(|e| {
OAuthHttpClientError::new(format!("invalid redirect target '{location}': {e}"))
})?;
let downgrade_to_get = response.status() == http::StatusCode::SEE_OTHER
|| ((response.status() == http::StatusCode::MOVED_PERMANENTLY
|| response.status() == http::StatusCode::FOUND)
&& prev.method() == http::Method::POST);
let (method, body) = if downgrade_to_get {
(http::Method::GET, Vec::new())
} else {
(prev.method().clone(), prev.body().clone())
};
let cross_origin = (base.scheme(), base.host_str(), base.port_or_known_default())
!= (
next_url.scheme(),
next_url.host_str(),
next_url.port_or_known_default(),
);
let body_dropped = body.is_empty();
let mut builder = http::Request::builder()
.method(method)
.uri(next_url.as_str());
for (name, value) in prev.headers() {
if body_dropped && is_body_describing_header(name) {
continue;
}
if cross_origin && is_cross_origin_sensitive_header(name) {
continue;
}
builder = builder.header(name, value);
}
builder
.body(body)
.map_err(|e| OAuthHttpClientError::new(e.to_string()))
}
async fn execute_with_policy(
&self,
mut request: http::Request<Vec<u8>>,
redirect_policy: OAuthHttpRedirectPolicy,
timeout: Option<Duration>,
) -> Result<http::Response<Vec<u8>>, OAuthHttpClientError> {
let mut hops = 0usize;
loop {
let response = self.execute_single(&request, timeout).await?;
if redirect_policy != OAuthHttpRedirectPolicy::Follow
|| !response.status().is_redirection()
{
return Ok(response);
}
hops += 1;
if hops > MAX_OAUTH_REDIRECT_HOPS {
return Err(OAuthHttpClientError::new(format!(
"OAuth request exceeded max redirect hops ({MAX_OAUTH_REDIRECT_HOPS})"
)));
}
request = Self::next_hop_request(&request, &response)?;
}
}
}
impl OAuthHttpClient for PinningOAuthHttpClient {
fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> {
Box::pin(async move {
let OAuthHttpRequest {
request,
redirect_policy,
timeout,
..
} = request;
self.execute_with_policy(request, redirect_policy, timeout)
.await
})
}
}
pub(crate) fn pinning_oauth_http_client(
server_id: &str,
trusted: bool,
) -> std::sync::Arc<dyn OAuthHttpClient> {
std::sync::Arc::new(PinningOAuthHttpClient::new(server_id, trusted))
}
#[cfg_attr(
feature = "profiling",
tracing::instrument(
name = "mcp.oauth.await_oauth_callback",
skip(listener),
fields(server_id)
)
)]
pub async fn await_oauth_callback(
listener: tokio::net::TcpListener,
timeout: Duration,
server_id: &str,
) -> Result<(String, String), McpError> {
let accept_fut = async {
let (mut stream, _) = listener.accept().await.map_err(|e| McpError::OAuthError {
server_id: server_id.to_owned(),
message: format!("callback server accept failed: {e}"),
})?;
let mut buf = Vec::with_capacity(4096);
let cap: usize = 8192;
loop {
let mut chunk = [0u8; 512];
let n = stream
.read(&mut chunk)
.await
.map_err(|e| McpError::OAuthError {
server_id: server_id.to_owned(),
message: format!("callback read failed: {e}"),
})?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.array_windows::<4>().any(|w| w == b"\r\n\r\n") || buf.len() >= cap {
break;
}
}
let request = String::from_utf8_lossy(&buf);
let first_line = request.lines().next().unwrap_or_default();
let path = first_line.split_whitespace().nth(1).unwrap_or_default();
let query = path.split_once('?').map(|(_, q)| q).unwrap_or_default();
let (code, state) = parse_callback_params(query, server_id)?;
let response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nAuthorization successful. You can close this tab.";
let _ = stream.write_all(response.as_bytes()).await;
Ok::<(String, String), McpError>((code, state))
};
tokio::time::timeout(timeout, accept_fut)
.await
.map_err(|_| McpError::OAuthCallbackTimeout {
server_id: server_id.to_owned(),
timeout_secs: timeout.as_secs(),
})?
}
fn parse_callback_params(query: &str, server_id: &str) -> Result<(String, String), McpError> {
let mut code = None;
let mut state = None;
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=') {
let v = urlencoding_decode(v);
match k {
"code" => code = Some(v),
"state" => state = Some(v),
_ => {}
}
}
}
let code = code.ok_or_else(|| McpError::OAuthError {
server_id: server_id.to_owned(),
message: "OAuth callback missing 'code' parameter".into(),
})?;
let state = state.ok_or_else(|| McpError::OAuthError {
server_id: server_id.to_owned(),
message: "OAuth callback missing 'state' parameter".into(),
})?;
Ok((code, state))
}
fn urlencoding_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push(char::from(h * 16 + l));
i += 3;
continue;
}
} else if bytes[i] == b'+' {
out.push(' ');
i += 1;
continue;
}
out.push(char::from(bytes[i]));
i += 1;
}
out
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg_attr(
feature = "profiling",
tracing::instrument(
name = "mcp.oauth.validate_oauth_metadata_urls",
skip(metadata),
fields(server_id)
)
)]
pub async fn validate_oauth_metadata_urls(
server_id: &str,
metadata: &rmcp::transport::auth::AuthorizationMetadata,
) -> Result<(), McpError> {
use crate::client::validate_url_ssrf;
validate_url_ssrf(&metadata.token_endpoint)
.await
.map_err(|_| McpError::OAuthError {
server_id: server_id.to_owned(),
message: format!(
"SSRF: token_endpoint '{}' resolves to private IP",
metadata.token_endpoint
),
})?;
if let Some(ref reg_url) = metadata.registration_endpoint {
validate_url_ssrf(reg_url)
.await
.map_err(|_| McpError::OAuthError {
server_id: server_id.to_owned(),
message: format!("SSRF: registration_endpoint '{reg_url}' resolves to private IP"),
})?;
}
validate_url_ssrf(&metadata.authorization_endpoint)
.await
.map_err(|_| McpError::OAuthError {
server_id: server_id.to_owned(),
message: format!(
"SSRF: authorization_endpoint '{}' resolves to private IP",
metadata.authorization_endpoint
),
})?;
if let Some(ref jwks) = metadata.jwks_uri {
validate_url_ssrf(jwks)
.await
.map_err(|_| McpError::OAuthError {
server_id: server_id.to_owned(),
message: format!("SSRF: jwks_uri '{jwks}' resolves to private IP"),
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[test]
fn urlencoding_decode_basic() {
assert_eq!(urlencoding_decode("hello+world"), "hello world");
assert_eq!(urlencoding_decode("foo%20bar"), "foo bar");
assert_eq!(urlencoding_decode("abc%2F"), "abc/");
}
#[test]
fn parse_callback_params_ok() {
let (code, state) = parse_callback_params("code=abc123&state=xyz", "srv").unwrap();
assert_eq!(code, "abc123");
assert_eq!(state, "xyz");
}
#[test]
fn parse_callback_params_missing_code() {
let err = parse_callback_params("state=xyz", "srv").unwrap_err();
assert_matches!(err, McpError::OAuthError { .. });
}
#[test]
fn parse_callback_params_missing_state() {
let err = parse_callback_params("code=abc", "srv").unwrap_err();
assert_matches!(err, McpError::OAuthError { .. });
}
#[test]
fn oauth_error_variants_display() {
let err = McpError::OAuthError {
server_id: "todoist".into(),
message: "metadata discovery failed".into(),
};
assert!(err.to_string().contains("todoist"));
assert!(err.to_string().contains("metadata discovery failed"));
let timeout = McpError::OAuthCallbackTimeout {
server_id: "todoist".into(),
timeout_secs: 300,
};
assert!(timeout.to_string().contains("300"));
}
#[tokio::test]
async fn validate_oauth_metadata_urls_blocks_private_token_endpoint() {
let mut metadata = rmcp::transport::auth::AuthorizationMetadata::default();
metadata.token_endpoint = "http://10.0.0.1/token".into();
metadata.authorization_endpoint = "http://8.8.8.8/auth".into();
let err = validate_oauth_metadata_urls("srv", &metadata)
.await
.unwrap_err();
assert_matches!(err, McpError::OAuthError { .. });
assert!(err.to_string().contains("token_endpoint"));
}
#[tokio::test]
async fn validate_oauth_metadata_urls_blocks_private_authorization_endpoint() {
let mut metadata = rmcp::transport::auth::AuthorizationMetadata::default();
metadata.token_endpoint = "http://8.8.8.8/token".into();
metadata.authorization_endpoint = "http://192.168.1.1/auth".into();
let err = validate_oauth_metadata_urls("srv", &metadata)
.await
.unwrap_err();
assert_matches!(err, McpError::OAuthError { .. });
assert!(err.to_string().contains("authorization_endpoint"));
}
#[tokio::test]
async fn validate_oauth_metadata_urls_blocks_private_jwks_uri() {
let mut metadata = rmcp::transport::auth::AuthorizationMetadata::default();
metadata.token_endpoint = "http://8.8.8.8/token".into();
metadata.authorization_endpoint = "http://8.8.8.8/auth".into();
metadata.jwks_uri = Some("http://127.0.0.1:9000/jwks".into());
let err = validate_oauth_metadata_urls("srv", &metadata)
.await
.unwrap_err();
assert_matches!(err, McpError::OAuthError { .. });
assert!(err.to_string().contains("jwks_uri"));
}
#[tokio::test]
async fn pinning_oauth_http_client_blocks_private_cross_origin_host() {
let client = PinningOAuthHttpClient::new("srv", false);
let err = client.build_client("10.0.0.1", 80, None).await.unwrap_err();
assert!(err.to_string().contains("SSRF protection"));
}
#[tokio::test]
async fn pinning_oauth_http_client_blocks_private_loopback_host() {
let client = PinningOAuthHttpClient::new("srv", false);
let err = client
.build_client("127.0.0.1", 443, None)
.await
.unwrap_err();
assert!(err.to_string().contains("SSRF protection"));
}
#[tokio::test]
async fn pinning_oauth_http_client_allows_public_ip_literal() {
let client = PinningOAuthHttpClient::new("srv", false);
client
.build_client("8.8.8.8", 443, None)
.await
.expect("public IP literal must not be blocked");
}
#[tokio::test]
async fn pinning_oauth_http_client_trusted_mode_skips_validation() {
let client = PinningOAuthHttpClient::new("srv", true);
client
.build_client("127.0.0.1", 443, None)
.await
.expect("trusted client must skip SSRF validation");
}
#[test]
fn next_hop_request_resolves_relative_location_against_request_uri() {
let prev = http::Request::builder()
.method("GET")
.uri("http://origin.example/start")
.body(Vec::new())
.unwrap();
let response = http::Response::builder()
.status(302)
.header("Location", "/next")
.body(Vec::new())
.unwrap();
let next = PinningOAuthHttpClient::next_hop_request(&prev, &response).unwrap();
assert_eq!(next.uri(), "http://origin.example/next");
assert_eq!(next.method(), http::Method::GET);
}
#[test]
fn next_hop_request_downgrades_post_to_get_on_302() {
let prev = http::Request::builder()
.method("POST")
.uri("http://origin.example/register")
.body(b"{}".to_vec())
.unwrap();
let response = http::Response::builder()
.status(302)
.header("Location", "http://other.example/next")
.body(Vec::new())
.unwrap();
let next = PinningOAuthHttpClient::next_hop_request(&prev, &response).unwrap();
assert_eq!(next.method(), http::Method::GET);
assert!(next.body().is_empty());
assert_eq!(next.uri(), "http://other.example/next");
}
#[test]
fn next_hop_request_preserves_method_and_body_on_307() {
let prev = http::Request::builder()
.method("POST")
.uri("http://origin.example/register")
.body(br#"{"x":1}"#.to_vec())
.unwrap();
let response = http::Response::builder()
.status(307)
.header("Location", "http://other.example/next")
.body(Vec::new())
.unwrap();
let next = PinningOAuthHttpClient::next_hop_request(&prev, &response).unwrap();
assert_eq!(next.method(), http::Method::POST);
assert_eq!(next.body(), br#"{"x":1}"#);
}
#[test]
fn next_hop_request_rejects_missing_location_header() {
let prev = http::Request::builder()
.method("GET")
.uri("http://origin.example/start")
.body(Vec::new())
.unwrap();
let response = http::Response::builder()
.status(302)
.body(Vec::new())
.unwrap();
let err = PinningOAuthHttpClient::next_hop_request(&prev, &response).unwrap_err();
assert!(err.to_string().contains("Location"));
}
#[test]
fn next_hop_request_strips_sensitive_headers_on_cross_origin_redirect() {
let prev = http::Request::builder()
.method("GET")
.uri("http://origin.example/start")
.header(http::header::AUTHORIZATION, "Bearer secret")
.header(http::header::COOKIE, "session=abc")
.body(Vec::new())
.unwrap();
let response = http::Response::builder()
.status(302)
.header("Location", "http://other.example/next")
.body(Vec::new())
.unwrap();
let next = PinningOAuthHttpClient::next_hop_request(&prev, &response).unwrap();
assert!(next.headers().get(http::header::AUTHORIZATION).is_none());
assert!(next.headers().get(http::header::COOKIE).is_none());
}
#[test]
fn next_hop_request_preserves_sensitive_headers_on_same_origin_redirect() {
let prev = http::Request::builder()
.method("GET")
.uri("http://origin.example/start")
.header(http::header::AUTHORIZATION, "Bearer secret")
.body(Vec::new())
.unwrap();
let response = http::Response::builder()
.status(307)
.header("Location", "/next")
.body(Vec::new())
.unwrap();
let next = PinningOAuthHttpClient::next_hop_request(&prev, &response).unwrap();
assert_eq!(
next.headers().get(http::header::AUTHORIZATION).unwrap(),
"Bearer secret"
);
}
#[test]
fn next_hop_request_strips_sensitive_headers_on_scheme_change_same_host() {
let prev = http::Request::builder()
.method("GET")
.uri("http://origin.example/start")
.header(http::header::AUTHORIZATION, "Bearer secret")
.body(Vec::new())
.unwrap();
let response = http::Response::builder()
.status(302)
.header("Location", "https://origin.example/next")
.body(Vec::new())
.unwrap();
let next = PinningOAuthHttpClient::next_hop_request(&prev, &response).unwrap();
assert!(next.headers().get(http::header::AUTHORIZATION).is_none());
}
#[test]
fn next_hop_request_drops_content_headers_when_body_downgraded() {
let prev = http::Request::builder()
.method("POST")
.uri("http://origin.example/register")
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::CONTENT_ENCODING, "gzip")
.body(b"{}".to_vec())
.unwrap();
let response = http::Response::builder()
.status(302)
.header("Location", "http://other.example/next")
.body(Vec::new())
.unwrap();
let next = PinningOAuthHttpClient::next_hop_request(&prev, &response).unwrap();
assert!(next.headers().get(http::header::CONTENT_TYPE).is_none());
assert!(next.headers().get(http::header::CONTENT_ENCODING).is_none());
assert!(next.body().is_empty());
}
#[tokio::test]
async fn pinning_oauth_http_client_stop_policy_returns_redirect_unfollowed() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(
wiremock::ResponseTemplate::new(302)
.insert_header("Location", "http://redirect-target.invalid/next"),
)
.mount(&server)
.await;
let client = PinningOAuthHttpClient::new("srv", true);
let request = http::Request::builder()
.method("GET")
.uri(format!("{}/start", server.uri()))
.body(Vec::new())
.unwrap();
let response = client
.execute_with_policy(request, OAuthHttpRedirectPolicy::Stop, None)
.await
.unwrap();
assert_eq!(response.status(), http::StatusCode::FOUND);
}
#[tokio::test]
async fn pinning_oauth_http_client_follows_redirect_to_different_host() {
let target = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("final"))
.mount(&target)
.await;
let origin = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(
wiremock::ResponseTemplate::new(302)
.insert_header("Location", format!("{}/next", target.uri())),
)
.mount(&origin)
.await;
let client = PinningOAuthHttpClient::new("srv", true);
let request = http::Request::builder()
.method("GET")
.uri(format!("{}/start", origin.uri()))
.body(Vec::new())
.unwrap();
let response = client
.execute_with_policy(request, OAuthHttpRedirectPolicy::Follow, None)
.await
.unwrap();
assert_eq!(response.status(), http::StatusCode::OK);
assert_eq!(response.body(), b"final");
}
#[tokio::test]
async fn pinning_oauth_http_client_post_redirect_downgrades_to_get() {
let target = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("got-get"))
.mount(&target)
.await;
let origin = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(302)
.insert_header("Location", format!("{}/next", target.uri())),
)
.mount(&origin)
.await;
let client = PinningOAuthHttpClient::new("srv", true);
let request = http::Request::builder()
.method("POST")
.uri(format!("{}/register", origin.uri()))
.body(b"{}".to_vec())
.unwrap();
let response = client
.execute_with_policy(request, OAuthHttpRedirectPolicy::Follow, None)
.await
.unwrap();
assert_eq!(response.status(), http::StatusCode::OK);
assert_eq!(response.body(), b"got-get");
}
#[tokio::test]
async fn pinning_oauth_http_client_bounds_redirect_hops() {
let server = wiremock::MockServer::start().await;
let loop_url = format!("{}/loop", server.uri());
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(
wiremock::ResponseTemplate::new(302).insert_header("Location", loop_url.as_str()),
)
.mount(&server)
.await;
let client = PinningOAuthHttpClient::new("srv", true);
let request = http::Request::builder()
.method("GET")
.uri(loop_url)
.body(Vec::new())
.unwrap();
let err = client
.execute_with_policy(request, OAuthHttpRedirectPolicy::Follow, None)
.await
.unwrap_err();
assert!(err.to_string().contains("max redirect hops"));
}
#[tokio::test]
async fn pinning_oauth_http_client_execute_single_blocks_ssrf_unsafe_target() {
let client = PinningOAuthHttpClient::new("srv", false);
let request = http::Request::builder()
.method("GET")
.uri("http://127.0.0.1:9/register")
.body(Vec::new())
.unwrap();
let err = client.execute_single(&request, None).await.unwrap_err();
assert!(err.to_string().contains("SSRF protection"));
}
}