1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
use std::collections::HashMap;
use std::time::Duration;
use keyhog_core::{HttpMethod, VerificationResult};
use reqwest::Client;
use crate::ssrf::{is_private_ip_addr, is_private_url};
pub(crate) const PRIVATE_URL_ERROR: &str = "blocked: private URL";
pub(crate) const HTTPS_ONLY_ERROR: &str = "blocked: HTTPS only";
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,
}
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 = match reqwest::Url::parse(raw_url) {
Ok(url) => url,
Err(e) => return Err(VerificationResult::Error(format!("invalid URL: {}", e))),
};
// SSRF check MUST come before HTTPS-only check to prevent information leakage
// about internal network topology via error message differentiation.
if !allow_private_ips && is_private_url(url.as_str()) {
return Err(VerificationResult::Error(PRIVATE_URL_ERROR.into()));
}
// Enforce HTTPS unconditionally in production. Plaintext loopback secret
// transmission was a known leak vector - see audit release-2026-04-26.
// Tests that need HTTP set `danger_allow_http=true` AND
// `danger_allow_private_ips=true` so production paths can never opt
// into either accidentally.
if !allow_http && url.scheme() != "https" {
return Err(VerificationResult::Error(HTTPS_ONLY_ERROR.into()));
}
// When a proxy is in use, DNS resolution is the proxy's job (the
// verifier sends an absolute-form HTTP request or HTTP CONNECT and
// the proxy resolves the target hostname). Pre-resolving on the
// verifier side and pinning via `.resolve_to_addrs` would build a
// per-request client that DROPS the proxy + insecure_tls config
// baked into `base_client` - exactly the macro-wiring bug we'"'"'re
// closing. Skip the pinning entirely; `base_client` already carries
// the proxy. The DNS-rebinding mitigation that pinning provides is
// moot through a proxy (the proxy resolves once; reqwest doesn'"'"'t
// re-resolve).
if proxy_in_use {
return Ok(ResolvedTarget {
client: base_client.clone(),
url,
});
}
// Direct connection (no proxy): resolve the host once and PIN that
// resolution into the per-request client via `resolve_to_addrs`. The
// DNS-rebinding fix (kimi-wave1 audit finding 4.2). Previously we
// only validated the first lookup; reqwest then re-resolved at
// connect time, allowing an attacker DNS server to return 1.1.1.1
// the first time and 127.0.0.1 the second. Pinning means the TCP
// connect uses the IP we already accepted - the second lookup never
// happens.
let mut pinned_addrs: Vec<std::net::SocketAddr> = Vec::new();
let host = url.host_str().unwrap_or_default().to_string();
let port = url.port_or_known_default().unwrap_or(443);
if !host.is_empty() {
// Skip DNS for raw IP literals - `lookup_host` handles them, but
// be explicit for clarity.
let target = format!("{host}:{port}");
let addrs: std::result::Result<Vec<std::net::SocketAddr>, std::io::Error> =
crate::ssrf::resolve_dns_cached(target.as_str()).await;
match addrs {
Ok(addrs) if addrs.is_empty() => {
return Err(VerificationResult::Error(
"blocked: DNS returned no addresses".into(),
));
}
Ok(addrs) => {
if !allow_private_ips && addrs.iter().any(|addr| is_private_ip_addr(&addr.ip())) {
return Err(VerificationResult::Error(PRIVATE_URL_ERROR.into()));
}
pinned_addrs = addrs;
}
Err(_) => {
return Err(VerificationResult::Error(
"blocked: DNS resolution failed".into(),
));
}
}
}
// Build a per-request client that pins host→addresses. `.resolve_to_addrs`
// bypasses the system resolver for this hostname, so reqwest's internal
// connector cannot re-resolve to a private IP between the check above
// and the TCP connect. Keep `base_client` for code paths that don't
// resolve a URL (e.g. AwsV4 self-constructing auth).
let client = if !pinned_addrs.is_empty() {
// The DNS-pinning rebuild MUST replicate the security-critical
// config baked into `base_client`. Reqwest's default ClientBuilder
// would otherwise:
// - follow redirects (Policy::limited(10)) - the base client sets
// Policy::none() to stop a public host from issuing a 302 to a
// private IP that bypasses the pre-connect SSRF check (the pin
// only covers the ORIGINAL host; the redirect target is
// re-resolved via the system resolver).
// - validate certs strictly - the base client honors
// `--insecure` (`config.insecure_tls`); dropping that here
// means the flag silently doesn't apply on the path that
// actually serves the request when no proxy is in use.
// Both gaps were live until 2026-05-26.
match Client::builder()
.timeout(timeout)
.danger_accept_invalid_certs(insecure_tls)
.redirect(reqwest::redirect::Policy::none())
.resolve_to_addrs(&host, &pinned_addrs)
.build()
{
Ok(c) => c,
Err(_) => {
// Fall back to the shared client. We already validated the
// resolved IPs above; this path is best-effort.
let _ = base_client;
base_client.clone()
}
}
} else {
base_client.clone()
};
Ok(ResolvedTarget { client, url })
}
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,
) -> RequestBuildResult {
let request = request_for_method(client, method, url).timeout(timeout);
crate::verify::auth::build_request_for_auth(
request, auth, credential, companions, timeout, client,
)
.await
}
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".into())
} else if e.is_redirect() {
VerificationResult::Error("too many redirects".into())
} else if e.is_connect() {
VerificationResult::Error("connection failed".into())
} else {
VerificationResult::Error("request failed".into())
},
transient: e.is_timeout() || e.is_connect(),
})
}