use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use keyhog_core::{HeaderSpec, HttpMethod, VerificationResult};
use reqwest::Client;
use crate::interpolate::{interpolate_http_value, missing_companion_refs};
use crate::ssrf::{is_private_ip_addr, is_private_url};
pub const PRIVATE_URL_ERROR: &str = "blocked: private URL";
pub const HTTPS_ONLY_ERROR: &str = "blocked: HTTPS only";
pub const DNS_NO_ADDRESSES_ERROR: &str = "blocked: DNS returned no addresses";
pub fn invalid_url_error(parse_error: impl std::fmt::Display) -> String {
format!(
"invalid URL: {parse_error}. Fix: the verification target URL is malformed, check the \
detector's `[detector.verify] url` (and any credential-interpolated host) in its TOML"
)
}
pub const TIMEOUT_ERROR: &str = "timeout: the endpoint did not respond within the \
verification deadline. Fix: raise the verification timeout with --timeout, or \
check network egress / proxy reachability to the credential's host";
pub const CONNECTION_FAILED_ERROR: &str = "connection failed: could not open a \
connection to the endpoint. Fix: check DNS resolution, firewall/egress rules, \
and proxy settings for the credential's host";
pub const REDIRECT_LIMIT_ERROR: &str = "too many redirects: the endpoint issued a \
redirect, but redirects are disabled for SSRF safety. Fix: set the detector's \
verification URL to the canonical API host so it answers directly without \
redirecting";
pub const REQUEST_FAILED_ERROR: &str = "request failed: the HTTP request errored \
before any response was received. Fix: check the endpoint URL, TLS \
configuration, and proxy settings for the credential's host";
const PINNED_CLIENT_CACHE_TTL: Duration = Duration::from_secs(60);
const PINNED_CLIENT_CACHE_MAX_ENTRIES: usize = 4096;
pub(crate) struct ResolvedTarget {
pub client: Client,
pub url: reqwest::Url,
}
pub(crate) enum RequestBuildResult {
Ready(reqwest::RequestBuilder),
Final {
result: VerificationResult,
metadata: HashMap<String, String>,
transient: bool,
},
}
pub(crate) struct RequestError {
pub result: VerificationResult,
pub transient: bool,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct PinnedClientKey {
host: String,
addrs: Vec<SocketAddr>,
timeout: Duration,
insecure_tls: bool,
}
struct CachedPinnedClient {
inserted_at: Instant,
client: Client,
}
static PINNED_CLIENT_CACHE: OnceLock<DashMap<PinnedClientKey, CachedPinnedClient>> =
OnceLock::new();
pub(crate) fn reject_private_resolved_addrs(
addrs: &[std::net::SocketAddr],
allow_private_ips: bool,
) -> std::result::Result<(), VerificationResult> {
if !allow_private_ips && addrs.iter().any(|addr| is_private_ip_addr(&addr.ip())) {
return Err(VerificationResult::Error(PRIVATE_URL_ERROR.into()));
}
Ok(())
}
fn screen_target_url_and_addrs(
url: &reqwest::Url,
addrs: &[std::net::SocketAddr],
allow_private_ips: bool,
) -> std::result::Result<(), VerificationResult> {
if !allow_private_ips && is_private_url(url.as_str()) {
return Err(VerificationResult::Error(PRIVATE_URL_ERROR.into()));
}
reject_private_resolved_addrs(addrs, allow_private_ips)
}
pub(crate) fn ssrf_check_url_with_resolved_addrs_for_test(
raw_url: &str,
addrs: &[std::net::SocketAddr],
allow_private_ips: bool,
) -> std::result::Result<(), VerificationResult> {
let url = parse_target_url(raw_url)?;
screen_target_url_and_addrs(&url, addrs, allow_private_ips)
}
pub(crate) async fn resolved_client_for_url(
base_client: &Client,
raw_url: &str,
timeout: Duration,
allow_private_ips: bool,
allow_http: bool,
proxy_in_use: bool,
insecure_tls: bool,
) -> std::result::Result<ResolvedTarget, VerificationResult> {
let url = parse_target_url(raw_url)?;
enforce_target_url_policy(&url, allow_private_ips, allow_http)?;
if proxy_in_use {
if !allow_private_ips {
let host = target_host(&url);
let _screened_addrs =
resolve_direct_target_addrs(&url, &host, allow_private_ips).await?;
}
return Ok(proxied_target(base_client, url));
}
let host = target_host(&url);
let pinned_addrs = resolve_direct_target_addrs(&url, &host, allow_private_ips).await?;
let client = direct_target_client(base_client, &host, &pinned_addrs, timeout, insecure_tls)?;
Ok(ResolvedTarget { client, url })
}
fn parse_target_url(raw_url: &str) -> std::result::Result<reqwest::Url, VerificationResult> {
reqwest::Url::parse(raw_url).map_err(|e| VerificationResult::Error(invalid_url_error(e)))
}
fn enforce_target_url_policy(
url: &reqwest::Url,
allow_private_ips: bool,
allow_http: bool,
) -> std::result::Result<(), VerificationResult> {
screen_target_url_and_addrs(url, &[], allow_private_ips)?;
if !allow_http && url.scheme() != "https" {
return Err(VerificationResult::Error(HTTPS_ONLY_ERROR.into()));
}
Ok(())
}
fn proxied_target(base_client: &Client, url: reqwest::Url) -> ResolvedTarget {
ResolvedTarget {
client: base_client.clone(),
url,
}
}
fn target_host(url: &reqwest::Url) -> String {
url.host_str().unwrap_or_default().to_string() }
async fn resolve_direct_target_addrs(
url: &reqwest::Url,
host: &str,
allow_private_ips: bool,
) -> std::result::Result<Vec<SocketAddr>, VerificationResult> {
if host.is_empty() {
return Ok(Vec::new());
}
let port = url
.port_or_known_default()
.unwrap_or(crate::DEFAULT_HTTPS_PORT); let target = format!("{host}:{port}");
match crate::ssrf::resolve_dns_cached(target.as_str()).await {
Ok(addrs) if addrs.is_empty() => {
Err(VerificationResult::Error(DNS_NO_ADDRESSES_ERROR.into()))
}
Ok(addrs) => {
screen_target_url_and_addrs(url, &addrs, allow_private_ips)?;
Ok(addrs)
}
Err(error) => {
Err(VerificationResult::Error(format!(
"blocked: DNS resolution failed: {error}"
)))
}
}
}
fn direct_target_client(
base_client: &Client,
host: &str,
pinned_addrs: &[SocketAddr],
timeout: Duration,
insecure_tls: bool,
) -> std::result::Result<Client, VerificationResult> {
if pinned_addrs.is_empty() {
return Ok(base_client.clone());
}
pinned_client_for(host, pinned_addrs, timeout, insecure_tls)
}
pub(crate) fn canonical_pinned_addrs(addrs: &[SocketAddr]) -> Vec<SocketAddr> {
let mut sorted = addrs.to_vec();
sorted.sort_unstable();
sorted
}
pub(crate) fn pinned_keys_equal_for_test(
host: &str,
addrs_a: &[SocketAddr],
addrs_b: &[SocketAddr],
timeout: Duration,
insecure_tls: bool,
) -> bool {
let key = |addrs: &[SocketAddr]| PinnedClientKey {
host: host.to_string(),
addrs: canonical_pinned_addrs(addrs),
timeout,
insecure_tls,
};
key(addrs_a) == key(addrs_b)
}
fn pinned_client_for(
host: &str,
pinned_addrs: &[SocketAddr],
timeout: Duration,
insecure_tls: bool,
) -> std::result::Result<Client, VerificationResult> {
let key = PinnedClientKey {
host: host.to_string(),
addrs: canonical_pinned_addrs(pinned_addrs),
timeout,
insecure_tls,
};
let cache = PINNED_CLIENT_CACHE.get_or_init(DashMap::new);
if let Some(entry) = cache.get(&key) {
if entry.inserted_at.elapsed() < PINNED_CLIENT_CACHE_TTL {
return Ok(entry.client.clone());
}
drop(entry);
cache.remove(&key);
}
if cache.len() >= PINNED_CLIENT_CACHE_MAX_ENTRIES {
crate::cache::evict_oldest_dashmap_entries(
cache,
crate::cache::oldest_eviction_batch(PINNED_CLIENT_CACHE_MAX_ENTRIES),
|client| client.inserted_at,
);
}
let client = build_pinned_client(host, pinned_addrs, timeout, insecure_tls)?;
cache.insert(
key,
CachedPinnedClient {
inserted_at: Instant::now(),
client: client.clone(),
},
);
Ok(client)
}
pub(crate) fn clear_pinned_client_cache_for_test() {
if let Some(cache) = PINNED_CLIENT_CACHE.get() {
cache.clear();
}
}
pub(crate) fn pinned_client_cache_len_for_test() -> usize {
PINNED_CLIENT_CACHE.get().map_or(0, DashMap::len)
}
pub(crate) fn pinned_client_cache_len_for_host_for_test(host: &str) -> usize {
PINNED_CLIENT_CACHE.get().map_or(0, |cache| {
cache
.iter()
.filter(|entry| entry.key().host == host)
.count()
})
}
pub(crate) fn pinned_client_for_test(
host: &str,
pinned_addrs: &[SocketAddr],
timeout: Duration,
insecure_tls: bool,
) -> std::result::Result<(), VerificationResult> {
pinned_client_for(host, pinned_addrs, timeout, insecure_tls).map(|_| ())
}
fn build_pinned_client(
host: &str,
pinned_addrs: &[SocketAddr],
timeout: Duration,
insecure_tls: bool,
) -> std::result::Result<Client, VerificationResult> {
crate::build_pinned_verifier_client(host, pinned_addrs, timeout, insecure_tls).map_err(|e| {
VerificationResult::Error(format!(
"blocked: DNS pin client build failed ({e}); refusing to \
fall back to an unpinned client (would reopen the \
DNS-rebinding window). Fix: report this verifier build"
))
})
}
pub(crate) async fn build_request_for_step(
client: &Client,
method: &HttpMethod,
auth: &keyhog_core::AuthSpec,
url: reqwest::Url,
credential: &str,
companions: &HashMap<String, String>,
timeout: Duration,
allow_private_ips: bool,
allow_http: bool,
proxy_in_use: bool,
insecure_tls: bool,
allow_script_verify: bool,
) -> RequestBuildResult {
let request = request_for_method(client, method, url).timeout(timeout);
crate::verify::auth::build_request_for_auth(
request,
auth,
credential,
companions,
timeout,
client,
allow_private_ips,
allow_http,
proxy_in_use,
insecure_tls,
allow_script_verify,
)
.await
}
pub(crate) fn apply_header_body_templates(
mut request: reqwest::RequestBuilder,
headers: &[HeaderSpec],
body_template: Option<&str>,
credential: &str,
companions: &HashMap<String, String>,
) -> reqwest::RequestBuilder {
for header in headers {
let value = interpolate_http_value(&header.value, credential, companions);
request = request.header(&header.name, &value);
}
if let Some(body_template) = body_template {
let body = interpolate_http_value(body_template, credential, companions);
request = request.body(body);
}
request
}
pub(crate) fn missing_companion_error(context: &str, missing: &[String]) -> VerificationResult {
VerificationResult::Error(format!(
"failed to resolve verification companion(s) in {context}: {}. Fix: configure detector companions that populate every companion.<name> reference before verification",
missing.join(", ")
))
}
pub(crate) fn validate_template_companions(
context: &str,
template: &str,
companions: &HashMap<String, String>,
) -> Result<(), VerificationResult> {
let missing = missing_companion_refs(template, companions);
if missing.is_empty() {
Ok(())
} else {
Err(missing_companion_error(context, &missing))
}
}
pub(crate) fn validate_header_body_templates(
headers: &[HeaderSpec],
body_template: Option<&str>,
companions: &HashMap<String, String>,
) -> Result<(), VerificationResult> {
for header in headers {
validate_template_companions("verification header", &header.value, companions)?;
}
if let Some(body_template) = body_template {
validate_template_companions("verification body", body_template, companions)?;
}
Ok(())
}
fn request_for_method(
client: &Client,
method: &HttpMethod,
url: reqwest::Url,
) -> reqwest::RequestBuilder {
match method {
HttpMethod::Get => client.get(url),
HttpMethod::Post => client.post(url),
HttpMethod::Put => client.put(url),
HttpMethod::Delete => client.delete(url),
HttpMethod::Patch => client.patch(url),
HttpMethod::Head => client.head(url),
}
}
pub(crate) async fn execute_request(
request: reqwest::RequestBuilder,
) -> std::result::Result<reqwest::Response, RequestError> {
request.send().await.map_err(|e| RequestError {
result: if e.is_timeout() {
VerificationResult::Error(TIMEOUT_ERROR.into())
} else if e.is_redirect() {
VerificationResult::Error(REDIRECT_LIMIT_ERROR.into())
} else if e.is_connect() {
VerificationResult::Error(CONNECTION_FAILED_ERROR.into())
} else {
VerificationResult::Error(REQUEST_FAILED_ERROR.into())
},
transient: e.is_timeout() || e.is_connect(),
})
}