keyhog_verifier/oob/client.rs
1//! Low-level interactsh protocol client.
2//!
3//! A thin async wrapper around the projectdiscovery/interactsh-server register/
4//! poll/deregister endpoints. Stateless aside from the RSA keypair, secret,
5//! correlation id, and HTTP client - `OobSession` (in `session.rs`) layers
6//! the per-finding subscription, polling loop, and notification fan-out on top.
7//!
8//! ## Crypto invariants
9//!
10//! - RSA-2048, OAEP padding, SHA-256 hash and MGF - interactsh-server speaks
11//! exactly this combination; `RSA_PKCS1_OAEP_PADDING` with SHA-256 in their
12//! Go code. Other parameters won't decrypt.
13//! - AES-256-CFB with a 16-byte IV prepended to ciphertext. Each interaction
14//! carries an independent IV; the AES key is per-poll-batch.
15//! - We never log credentials, public keys, or decrypted payloads. Errors
16//! carry stable strings - useful for support, opaque to leaks.
17
18use std::sync::{LazyLock, Mutex};
19use std::time::Duration;
20
21use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
22use rand::{rngs::OsRng, Rng};
23use reqwest::Client;
24use rsa::pkcs8::{EncodePublicKey, LineEnding};
25use rsa::{Oaep, RsaPrivateKey, RsaPublicKey};
26use serde::{Deserialize, Serialize};
27use sha2::Sha256;
28use thiserror::Error;
29use tracing::{debug, warn};
30
31/// Stable bucket name for the global rate limiter. Every OOB call across
32/// every detector shares this bucket so the aggregate request rate to the
33/// upstream collector never exceeds the configured `--verify-rate`. Using
34/// the literal string `"oob.interactsh"` (not the server URL) means the
35/// budget covers all configured collectors collectively - the limit is
36/// about our own machine not blasting traffic, not about per-host fairness.
37const OOB_SERVICE: &str = "oob.interactsh";
38const OOB_LIFECYCLE_BURST: usize = 3;
39const DNS_TOKEN_ALPHABET: &[u8; 36] = b"abcdefghijklmnopqrstuvwxyz0123456789";
40const CORRELATION_ID_LEN: usize = 24;
41const UNIQUE_SUFFIX_LEN: usize = 24;
42
43/// All errors that can arise from the OOB client. `Transient` errors mean the
44/// caller should retry (network blip, rate-limit); everything else is final.
45#[derive(Debug, Error)]
46pub enum InteractshError {
47 #[error("interactsh keypair generation failed: {0}")]
48 KeyGen(String),
49 #[error("interactsh public-key encoding failed: {0}")]
50 KeyEncode(String),
51 #[error("interactsh register failed (HTTP {status}): {body}")]
52 Register { status: u16, body: String },
53 #[error("interactsh deregister failed (HTTP {status}): {body}")]
54 Deregister { status: u16, body: String },
55 #[error("interactsh poll failed (HTTP {status}): {body}")]
56 Poll { status: u16, body: String },
57 #[error("interactsh response shape unexpected: {0}")]
58 BadResponse(String),
59 #[error("interactsh collector host blocked by SSRF guard: {0}")]
60 BlockedCollector(String),
61 #[error("interactsh AES key unwrap failed: {0}")]
62 AesUnwrap(String),
63 #[error("interactsh interaction decrypt failed: {0}")]
64 Decrypt(String),
65 #[error("interactsh transport error: {0}")]
66 Transport(#[from] reqwest::Error),
67 #[error("interactsh request timed out after {0:?}")]
68 Timeout(Duration),
69}
70
71/// Protocol category of a received interaction.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum InteractionProtocol {
74 Dns,
75 Http,
76 Smtp,
77 Other,
78}
79
80impl InteractionProtocol {
81 // `#[doc(hidden)] pub` rather than `pub(super)`: the OOB protocol-string
82 // parser is exercised directly by the boundary test
83 // `oob_interaction_protocol_parse_exact`. Hidden from the rendered API
84 // it is an internal categorizer, not a semver-covered surface.
85 #[doc(hidden)]
86 pub fn parse(s: &str) -> Self {
87 match s.to_ascii_lowercase().as_str() {
88 "dns" => Self::Dns,
89 "http" => Self::Http,
90 "smtp" | "smtp-mail" => Self::Smtp,
91 _ => Self::Other,
92 }
93 }
94}
95
96/// One decrypted interaction returned by the collector.
97#[derive(Debug, Clone)]
98pub struct Interaction {
99 /// Full unique id (correlation-id || per-finding suffix). This is
100 /// what we match against per-finding URLs we minted.
101 pub unique_id: String,
102 pub protocol: InteractionProtocol,
103 pub remote_address: String,
104 pub timestamp: String,
105 /// Raw protocol payload (HTTP request line + headers, DNS query, etc.).
106 /// Sized - interactsh truncates server-side, but we cap to 16 KiB here as
107 /// a defense-in-depth budget against memory abuse from a hostile server.
108 pub raw_payload: String,
109}
110
111/// One interactsh registration. Cheap to clone (Arc-friendly fields only on
112/// caller's side; here we hold owned values because the session pins this
113/// for the lifetime of the engine).
114pub struct InteractshClient {
115 /// Collector client after the OOB SSRF/DNS policy is applied. On direct
116 /// connections this client pins the collector host to the screened DNS
117 /// answers; with an explicit proxy it is the caller-provided proxy client
118 /// after the string-level private-host block has run.
119 http: Client,
120 server: String,
121 correlation_id: String,
122 secret_key: String,
123 private_key: RsaPrivateKey,
124 /// Length of the per-URL suffix. Production uses 24 DNS-safe characters so
125 /// a known correlation id still leaves >122 bits of per-finding entropy.
126 suffix_len: usize,
127}
128
129/// One shared 2048-bit RSA key for every `for_test` client. Generated ONCE
130/// (lazily, on first test use) and cloned into each instance, so the many test
131/// callers of [`InteractshClient::for_test`] pay a SINGLE 2048-bit keygen
132/// instead of one per call, full weak-crypto hygiene (NIST-minimum modulus)
133/// without the per-test keygen cost that a naive `RsaPrivateKey::new(_, 2048)`
134/// in the constructor would impose. Never initialized in a production build
135/// (`for_test` is test-only), so it costs nothing there.
136static TEST_RSA_KEY: LazyLock<Result<RsaPrivateKey, String>> =
137 LazyLock::new(|| RsaPrivateKey::new(&mut OsRng, 2048).map_err(|e| e.to_string()));
138type GeneratedPrivateKey = Result<RsaPrivateKey, String>;
139
140static PREWARMED_RSA_KEY: LazyLock<Mutex<Option<std::thread::JoinHandle<GeneratedPrivateKey>>>> =
141 LazyLock::new(|| Mutex::new(None));
142
143/// Start the OOB session key generation before findings reach verification.
144///
145/// Registration consumes this one-shot key. A second registration still
146/// generates a fresh key, preserving the session-key isolation contract.
147pub fn prewarm_key_generation() {
148 let mut slot = PREWARMED_RSA_KEY
149 .lock()
150 .unwrap_or_else(std::sync::PoisonError::into_inner); // LAW10: poisoned one-shot key-slot recovery preserves the stored join handle; registration still returns key-generation failures.
151 if slot.is_some() {
152 return;
153 }
154 match std::thread::Builder::new()
155 .name("keyhog-oob-keygen".to_owned())
156 .spawn(generate_private_key)
157 {
158 Ok(handle) => *slot = Some(handle),
159 Err(error) => {
160 debug!(target: "keyhog::oob", %error, "could not prewarm OOB key generation");
161 }
162 }
163}
164
165fn generate_private_key() -> GeneratedPrivateKey {
166 RsaPrivateKey::new(&mut OsRng, 2048).map_err(|error| error.to_string())
167}
168
169fn take_or_generate_private_key() -> Result<RsaPrivateKey, InteractshError> {
170 let prewarmed = PREWARMED_RSA_KEY
171 .lock()
172 .unwrap_or_else(std::sync::PoisonError::into_inner) // LAW10: poisoned one-shot key-slot recovery takes the same handle; join and key-generation failures remain explicit.
173 .take();
174 let generated = match prewarmed {
175 Some(handle) => handle
176 .join()
177 .map_err(|_| InteractshError::KeyGen("prewarm thread panicked".to_owned()))?,
178 None => generate_private_key(),
179 };
180 generated.map_err(InteractshError::KeyGen)
181}
182pub(crate) fn prewarmed_key_pending_for_test() -> bool {
183 PREWARMED_RSA_KEY
184 .lock()
185 .unwrap_or_else(std::sync::PoisonError::into_inner) // LAW10: test-only state inspection recovers the one-shot slot without affecting verifier behavior.
186 .is_some()
187}
188
189pub(crate) fn consume_prewarmed_key_for_test() -> Result<Vec<u8>, InteractshError> {
190 use rsa::traits::PublicKeyParts;
191
192 let prewarmed = PREWARMED_RSA_KEY
193 .lock()
194 .unwrap_or_else(std::sync::PoisonError::into_inner) // LAW10: test-only key consumption recovers the slot while missing, panic, and generation failures remain explicit.
195 .take()
196 .ok_or_else(|| InteractshError::KeyGen("prewarmed key missing".to_owned()))?;
197 let key = prewarmed
198 .join()
199 .map_err(|_| InteractshError::KeyGen("prewarm thread panicked".to_owned()))?
200 .map_err(InteractshError::KeyGen)?;
201 Ok(key.n().to_bytes_be())
202}
203
204impl InteractshClient {
205 /// Test-only constructor without network registration. Returns
206 /// `Err` if the shared test RSA keygen failed - which never happens on a
207 /// healthy platform, but propagating the error keeps this constructor
208 /// off the no-panic-in-production gate and matches the rest of the
209 /// `InteractshError` surface. Test callers wrap with `.unwrap()` at
210 /// the test boundary.
211 pub(crate) fn for_test(server: &str) -> Result<Self, InteractshError> {
212 // Clone the shared 2048-bit key (see `TEST_RSA_KEY`): NIST-minimum
213 // modulus, one keygen amortized across every test caller.
214 let private_key = TEST_RSA_KEY
215 .as_ref()
216 .map_err(|e| InteractshError::KeyGen(e.clone()))?
217 .clone();
218 Ok(Self {
219 http: Client::new(),
220 server: normalize_server(server),
221 correlation_id: "abcdefghijklmnopqrstuvwx".to_string(),
222 secret_key: "test-secret".to_string(),
223 private_key,
224 suffix_len: UNIQUE_SUFFIX_LEN,
225 })
226 }
227}
228
229/// JSON shapes from interactsh-server. Field names match the upstream Go
230/// definitions (`pkg/server/types.go`). `serde(default)` keeps us forward-
231/// compatible with future fields.
232#[derive(Serialize)]
233struct RegisterRequest<'a> {
234 #[serde(rename = "public-key")]
235 public_key: &'a str,
236 #[serde(rename = "secret-key")]
237 secret_key: &'a str,
238 #[serde(rename = "correlation-id")]
239 correlation_id: &'a str,
240}
241
242#[derive(Deserialize, Default)]
243#[serde(default)]
244struct PollResponse {
245 /// Each entry is base64( AES-256-CFB( IV[16] || ciphertext ) ).
246 data: Vec<String>,
247 /// Auxiliary metadata; ignored.
248 #[serde(rename = "extra")]
249 _extra: Vec<String>,
250 /// Base64( RSA-OAEP-SHA256( 32-byte AES key ) ). Server omits when there
251 /// are no interactions; in that case `data` is also empty.
252 aes_key: Option<String>,
253}
254
255/// Decrypted interaction shape. `serde(default)` because interactsh-server
256/// sometimes ships partial events (failed protocol parse, etc.) and we'd
257/// rather degrade gracefully than 500.
258/// Hard cap on the body of a `/poll` response. Protects the process from a
259/// hostile or misbehaving collector returning a multi-gigabyte JSON that
260/// would force `serde_json::from_slice` to allocate the whole thing
261/// in-memory before we can validate it. 4 MiB comfortably fits any
262/// reasonable poll batch - see the rationale at the call site.
263const MAX_POLL_BODY_BYTES: usize = 4 * 1024 * 1024;
264
265/// Cap on error/diagnostic bodies. We only display the first 256 chars in
266/// the error message anyway, but the cap prevents a server returning a
267/// 500 with a 1 GiB body from spiking memory.
268const ERROR_BODY_CAP: usize = 64 * 1024;
269
270/// Stream a response body into a Vec under a hard byte cap. Returns
271/// `BadResponse` if the cap is exceeded - abort the read rather than
272/// trust the server's framing.
273async fn read_capped_bytes(
274 resp: reqwest::Response,
275 cap: usize,
276) -> Result<Vec<u8>, InteractshError> {
277 use futures_util::StreamExt;
278 let mut stream = resp.bytes_stream();
279 let mut buf: Vec<u8> = Vec::new();
280 while let Some(chunk) = stream.next().await {
281 let chunk = chunk.map_err(InteractshError::Transport)?;
282 if buf.len().saturating_add(chunk.len()) > cap {
283 return Err(InteractshError::BadResponse(format!(
284 "response body exceeds {cap}-byte cap"
285 )));
286 }
287 buf.extend_from_slice(&chunk);
288 }
289 Ok(buf)
290}
291
292/// Like `read_capped_bytes` but for diagnostic error messages - never
293/// returns `Err`; on a stream failure or cap breach it returns whatever
294/// was buffered so the error log can still surface something.
295async fn read_capped_text(resp: reqwest::Response, cap: usize) -> String {
296 use futures_util::StreamExt;
297 let mut stream = resp.bytes_stream();
298 let mut buf: Vec<u8> = Vec::new();
299 while let Some(chunk) = stream.next().await {
300 let Ok(chunk) = chunk else { break };
301 if buf.len().saturating_add(chunk.len()) > cap {
302 break;
303 }
304 buf.extend_from_slice(&chunk);
305 }
306 String::from_utf8_lossy(&buf).into_owned()
307}
308
309impl InteractshClient {
310 /// Build, generate keys, and register with the collector. The returned
311 /// client is ready to mint URLs and be polled.
312 pub async fn register(http: Client, server: &str) -> Result<Self, InteractshError> {
313 Self::register_with_network_policy(http, server, Duration::from_secs(30), false, false)
314 .await
315 }
316
317 pub(crate) async fn register_with_network_policy(
318 http: Client,
319 server: &str,
320 timeout: Duration,
321 proxy_in_use: bool,
322 insecure_tls: bool,
323 ) -> Result<Self, InteractshError> {
324 // RSA-2048 keygen runs before verification when the CLI can overlap it
325 // with scanning. Direct library callers retain the blocking-pool path.
326 let private_key = tokio::task::spawn_blocking(take_or_generate_private_key)
327 .await
328 .map_err(|error| InteractshError::KeyGen(format!("join error: {error}")))??;
329
330 let public_key = RsaPublicKey::from(&private_key);
331 let pem = public_key
332 .to_public_key_pem(LineEnding::LF)
333 .map_err(|e| InteractshError::KeyEncode(e.to_string()))?;
334 let public_key_b64 = B64.encode(pem.as_bytes());
335
336 // Correlation id is 24 lowercase alphanumerics - interactsh-server
337 // matches incoming subdomains by this prefix, so the ID space must
338 // be wide enough that collisions are statistically impossible across
339 // every concurrent scanner sharing the collector. 36^24 ≈ 2.2e37.
340 let correlation_id = random_dns_token(CORRELATION_ID_LEN);
341 let secret_key = uuid::Uuid::new_v4().to_string();
342
343 let server = normalize_server(server);
344 let collector_http =
345 collector_http_client(&http, &server, timeout, proxy_in_use, insecure_tls).await?;
346
347 let body = RegisterRequest {
348 public_key: &public_key_b64,
349 secret_key: &secret_key,
350 correlation_id: &correlation_id,
351 };
352 // One OOB session has a bounded three-request lifecycle: register,
353 // poll, and deregister. A three-token burst avoids serial startup
354 // sleeps while the shared bucket still enforces the configured
355 // sustained collector rate for every later poll.
356 crate::rate_limit::get_rate_limiter()
357 .wait_with_burst(OOB_SERVICE, OOB_LIFECYCLE_BURST)
358 .await;
359 let resp = collector_http
360 .post(format!("{server}/register"))
361 .json(&body)
362 .send()
363 .await?;
364 let status = resp.status();
365 if !status.is_success() {
366 let body = read_capped_text(resp, ERROR_BODY_CAP).await;
367 return Err(InteractshError::Register {
368 status: status.as_u16(),
369 body: body.chars().take(256).collect(),
370 });
371 }
372 // Drain (and discard) the register success body under a cap. Some
373 // interactsh deployments echo registration metadata; we don't need
374 // it but must not let the connection sit half-read indefinitely.
375 let _ = read_capped_bytes(resp, ERROR_BODY_CAP).await; // LAW10: unused-binding marker; no runtime effect, not a fallback
376 debug!(target: "keyhog::oob", correlation_id = %correlation_id, server = %server, "registered with interactsh collector");
377
378 Ok(Self {
379 http: collector_http,
380 server,
381 correlation_id,
382 secret_key,
383 private_key,
384 suffix_len: UNIQUE_SUFFIX_LEN,
385 })
386 }
387
388 /// Mint a fresh callback URL bound to this session. The full unique-id
389 /// subdomain is returned (unique-id) plus the host the service should
390 /// hit. Caller is responsible for embedding it where the credential's
391 /// API will follow.
392 pub(crate) fn mint_url(&self) -> MintedUrl {
393 let suffix = random_dns_token(self.suffix_len);
394 let unique_id = format!("{}{}", self.correlation_id, suffix);
395 let host = format!("{}.{}", unique_id, self.server_host());
396 let url = format!("https://{host}");
397 MintedUrl {
398 unique_id,
399 host,
400 url,
401 }
402 }
403
404 /// Poll once. Returns every interaction the collector has buffered for
405 /// this correlation id since the last poll.
406 pub async fn poll(&self) -> Result<Vec<Interaction>, InteractshError> {
407 // The bounded lifecycle burst is shared with register and deregister;
408 // subsequent polls remain spaced at the configured sustained rate.
409 crate::rate_limit::get_rate_limiter()
410 .wait_with_burst(OOB_SERVICE, OOB_LIFECYCLE_BURST)
411 .await;
412 let resp = self
413 .http
414 .get(format!("{}/poll", self.server))
415 .query(&[("id", &self.correlation_id), ("secret", &self.secret_key)])
416 .send()
417 .await?;
418 let status = resp.status();
419 if !status.is_success() {
420 let body = read_capped_text(resp, ERROR_BODY_CAP).await;
421 return Err(InteractshError::Poll {
422 status: status.as_u16(),
423 body: body.chars().take(256).collect(),
424 });
425 }
426 // Bound the response body before deserialization. A malicious or
427 // misbehaving collector could otherwise blow process memory by
428 // returning a multi-gigabyte JSON. 4 MiB comfortably fits even a
429 // dense poll batch (≤100 interactions × ~16 KiB raw_payload each
430 // base64-expanded ≈ 2 MiB) with headroom.
431 let body = read_capped_bytes(resp, MAX_POLL_BODY_BYTES).await?;
432 let parsed: PollResponse = serde_json::from_slice(&body)
433 .map_err(|e| InteractshError::BadResponse(e.to_string()))?;
434 if parsed.data.is_empty() {
435 return Ok(Vec::new());
436 }
437 let aes_key_b64 = parsed.aes_key.ok_or_else(|| {
438 InteractshError::BadResponse("data present but aes_key missing".into())
439 })?;
440 let aes_key = self.unwrap_aes_key(&aes_key_b64)?;
441 if aes_key.len() != 32 {
442 return Err(InteractshError::AesUnwrap(format!(
443 "expected 32-byte AES-256 key, got {}",
444 aes_key.len()
445 )));
446 }
447
448 let mut out = Vec::with_capacity(parsed.data.len());
449 for entry in parsed.data {
450 match super::decrypt::decrypt_entry(&aes_key, &entry) {
451 Ok(Some(interaction)) => out.push(interaction),
452 Ok(None) => {} // decrypt_entry already warned for the dropped interaction
453 Err(e) => {
454 warn!(target: "keyhog::oob", error = %e, "interactsh entry decrypt failed; skipping")
455 }
456 }
457 }
458 Ok(out)
459 }
460
461 /// Tear down the registration. Idempotent on the server side; a failure
462 /// to deregister is non-fatal - the server prunes inactive sessions
463 /// after its retention window.
464 pub async fn deregister(&self) -> Result<(), InteractshError> {
465 #[derive(Serialize)]
466 struct DeregisterRequest<'a> {
467 #[serde(rename = "correlation-id")]
468 correlation_id: &'a str,
469 #[serde(rename = "secret-key")]
470 secret_key: &'a str,
471 }
472 // See `register` for the bounded lifecycle-burst rationale.
473 crate::rate_limit::get_rate_limiter()
474 .wait_with_burst(OOB_SERVICE, OOB_LIFECYCLE_BURST)
475 .await;
476 let resp = self
477 .http
478 .post(format!("{}/deregister", self.server))
479 .json(&DeregisterRequest {
480 correlation_id: &self.correlation_id,
481 secret_key: &self.secret_key,
482 })
483 .send()
484 .await?;
485 let status = resp.status();
486 if !status.is_success() {
487 // Cap the diagnostic body exactly like register/poll. An uncapped
488 // `resp.text()` here let a hostile or misbehaving collector force an
489 // unbounded allocation by returning a multi-GiB body on a
490 // deregister-failure status (the one error path that previously
491 // skipped the shared `read_capped_text` budget). We only display
492 // the first 256 chars in the error anyway.
493 let body: String = read_capped_text(resp, ERROR_BODY_CAP)
494 .await
495 .chars()
496 .take(256)
497 .collect();
498 warn!(target: "keyhog::oob", status = %status, body = %body, "interactsh deregister failed");
499 return Err(InteractshError::Deregister {
500 status: status.as_u16(),
501 body,
502 });
503 }
504 // Drain (and discard) the success body under a cap, exactly like
505 // `register`. Some interactsh deployments echo deregister metadata;
506 // leaving the connection half-read can poison it for the next pooled
507 // request on the same host.
508 let _ = read_capped_bytes(resp, ERROR_BODY_CAP).await; // LAW10: unused-binding marker; no runtime effect, not a fallback
509 Ok(())
510 }
511
512 pub(crate) fn correlation_id(&self) -> &str {
513 &self.correlation_id
514 }
515
516 /// `oast.fun` from `https://oast.fun/`.
517 fn server_host(&self) -> &str {
518 // strip scheme; we normalized at register time so no path component.
519 self.server
520 .split_once("://")
521 .map(|(_, rest)| rest)
522 .unwrap_or(&self.server) // LAW10: absent name/label => display default; reporting-only, recall-safe
523 .trim_end_matches('/')
524 }
525
526 fn unwrap_aes_key(&self, b64: &str) -> Result<Vec<u8>, InteractshError> {
527 let wrapped = B64
528 .decode(b64.as_bytes())
529 .map_err(|e| InteractshError::AesUnwrap(format!("base64: {e}")))?;
530 let padding = Oaep::new::<Sha256>();
531 self.private_key
532 .decrypt(padding, &wrapped)
533 .map_err(|e| InteractshError::AesUnwrap(format!("rsa-oaep: {e}")))
534 }
535}
536
537fn random_dns_token(len: usize) -> String {
538 let mut rng = OsRng;
539 (0..len)
540 .map(|_| {
541 let idx = rng.gen_range(0..DNS_TOKEN_ALPHABET.len());
542 DNS_TOKEN_ALPHABET[idx] as char
543 })
544 .collect()
545}
546
547/// One per-finding callback URL, returned from `InteractshClient::mint_url`.
548#[derive(Debug, Clone)]
549pub struct MintedUrl {
550 /// Full id; the value the service will reflect in DNS/HTTP host.
551 pub unique_id: String,
552 /// `<unique_id>.<server-host>` - bare host without scheme.
553 pub host: String,
554 /// `https://<host>` - convenience for HTTP-shaped probes.
555 pub url: String,
556}
557
558/// Build the only HTTP client OOB collector traffic may use.
559///
560/// Direct connections mirror `resolved_client_for_url`: block private-looking
561/// collector URLs, resolve once, reject any private resolved address, then pin
562/// the accepted addresses into reqwest via `resolve_to_addrs`. Register, poll,
563/// and deregister all use this stored client, so a collector host cannot pass a
564/// first DNS screen and later rebind the unattended poller to an internal
565/// service that receives the session secret.
566///
567/// With an explicit proxy, DNS resolution belongs to the proxy. We still run
568/// the string-level private-host block locally, then keep the caller-provided
569/// proxy client instead of rebuilding a direct client that would drop proxy
570/// policy.
571async fn collector_http_client(
572 base_client: &Client,
573 server: &str,
574 timeout: Duration,
575 proxy_in_use: bool,
576 insecure_tls: bool,
577) -> Result<Client, InteractshError> {
578 // String-level block first: refuse a private/loopback/link-local *literal*
579 // (or an unparseable/non-http(s)) collector URL before spending a DNS
580 // lookup on it.
581 if crate::ssrf::is_private_url(server) {
582 return Err(InteractshError::BlockedCollector(format!(
583 "{server} resolves to a private/loopback/link-local address"
584 )));
585 }
586
587 // Resolve, then screen the collector's IPs on BOTH the direct and proxied
588 // paths via the ONE decision owner below. Previously the proxied path
589 // returned the caller's client BEFORE any DNS screen, so a collector host
590 // that resolved to an internal address slipped past the string block and
591 // the proxy forwarded the session secret to it (proxy-SSRF / DNS
592 // rebinding). `collector_client_plan` screens first, then decides.
593 let (host, host_port) = collector_host_and_port(server)?;
594 let resolved = crate::ssrf::resolve_dns_cached(&host_port).await;
595
596 match collector_client_plan(server, proxy_in_use, resolved)? {
597 // DNS belongs to the proxy, so we cannot pin addresses into a proxied
598 // client; the local screen above already rejected an internal resolve.
599 // Keep the caller's proxy client after the screen.
600 CollectorClientPlan::UseProxy => Ok(base_client.clone()),
601 // ONE owner for the pinned rebuild, identical posture to the per-request
602 // verify client; see `crate::build_pinned_verifier_client`.
603 CollectorClientPlan::Pin(pinned_addrs) => {
604 crate::build_pinned_verifier_client(&host, &pinned_addrs, timeout, insecure_tls)
605 .map_err(|error| {
606 InteractshError::BlockedCollector(format!(
607 "{server} DNS pin client build failed ({error}); refusing an unpinned collector client"
608 ))
609 })
610 }
611 }
612}
613
614/// Which client the OOB collector policy permits, after the resolved-IP screen.
615enum CollectorClientPlan {
616 /// Proxy in use: screen passed; reuse the caller's proxy client (DNS is the
617 /// proxy's job; we cannot pin addresses into a proxied client).
618 UseProxy,
619 /// Direct connection: screen passed; pin these screened addresses.
620 Pin(Vec<std::net::SocketAddr>),
621}
622
623/// ONE owner for the collector resolved-IP screen + client decision, applied
624/// identically on the direct and proxied paths so neither can forward the
625/// session secret to a host that resolved to an internal address.
626fn collector_client_plan(
627 server: &str,
628 proxy_in_use: bool,
629 resolved: std::io::Result<Vec<std::net::SocketAddr>>,
630) -> Result<CollectorClientPlan, InteractshError> {
631 let addrs = resolved.map_err(|error| collector_dns_failure(server, error))?;
632 check_collector_resolved_addrs(server, &addrs)?;
633 if proxy_in_use {
634 Ok(CollectorClientPlan::UseProxy)
635 } else {
636 Ok(CollectorClientPlan::Pin(addrs))
637 }
638}
639
640pub(crate) fn ssrf_check_collector_dns_result_for_test(
641 server: &str,
642 resolved: std::io::Result<Vec<std::net::SocketAddr>>,
643) -> Result<(), InteractshError> {
644 let _host_port = collector_host_and_port(server)?;
645 collector_client_plan(server, false, resolved).map(|_plan| ())
646}
647
648/// Test seam for the proxy-aware screen decision: returns `true` when the plan
649/// reuses the proxy client, `false` when it pins a direct client, and `Err`
650/// when the screen rejects the resolved addresses. Proves the proxied path
651/// screens resolved IPs (a rebinding host resolving to an internal address is
652/// rejected even with `proxy_in_use = true`).
653pub(crate) fn collector_reuses_proxy_client_for_test(
654 server: &str,
655 proxy_in_use: bool,
656 resolved: std::io::Result<Vec<std::net::SocketAddr>>,
657) -> Result<bool, InteractshError> {
658 match collector_client_plan(server, proxy_in_use, resolved)? {
659 CollectorClientPlan::UseProxy => Ok(true),
660 CollectorClientPlan::Pin(_) => Ok(false),
661 }
662}
663
664fn collector_host_and_port(server: &str) -> Result<(String, String), InteractshError> {
665 let url = url::Url::parse(server).map_err(|_error| {
666 InteractshError::BlockedCollector(format!("{server} is not a parseable collector URL"))
667 })?;
668 let host = url.host_str().ok_or_else(|| {
669 InteractshError::BlockedCollector(format!("{server} has no collector host"))
670 })?;
671 let port = url
672 .port_or_known_default()
673 .unwrap_or(crate::DEFAULT_HTTPS_PORT); // LAW10: no explicit port => scheme default; recall-irrelevant
674 Ok((host.to_string(), format!("{host}:{port}")))
675}
676
677fn collector_dns_failure(server: &str, error: std::io::Error) -> InteractshError {
678 InteractshError::BlockedCollector(format!(
679 "{server} DNS resolution failed before SSRF screening: {error}; collector was not contacted"
680 ))
681}
682
683fn check_collector_resolved_addrs(
684 server: &str,
685 addrs: &[std::net::SocketAddr],
686) -> Result<(), InteractshError> {
687 if addrs.is_empty() {
688 return Err(InteractshError::BlockedCollector(format!(
689 "{server} DNS returned no addresses before SSRF screening; collector was not contacted"
690 )));
691 }
692 if addrs
693 .iter()
694 .any(|addr| crate::ssrf::is_private_ip_addr(&addr.ip()))
695 {
696 return Err(InteractshError::BlockedCollector(format!(
697 "{server} resolves to a private/loopback/link-local address"
698 )));
699 }
700 Ok(())
701}
702
703/// Accept `oast.fun`, `oast.fun/`, `https://oast.fun`, `https://oast.fun/`.
704/// Always return `https://<host>[:<port>]` with scheme/host/port ONLY. HTTP-only
705/// is force-upgraded because the AES key flowing back must travel TLS-wrapped.
706///
707/// Keeping only scheme/host/port is load-bearing for host safety: a collector
708/// string carrying a path (`oast.fun/evil`) or userinfo (`oast.fun@internal`)
709/// would otherwise survive into `server_host()` and mint a malformed
710/// `<id>.oast.fun/evil` callback host, or, worse, let the userinfo `@`
711/// redirect the real connect target. We re-serialize from a parsed URL so the
712/// stored `server` is exactly `https://<host>[:<port>]`.
713///
714/// An unparseable / hostless input is returned scheme-forced but otherwise
715/// untouched; it is not silently "cleaned" into something connectable, the
716/// downstream `is_private_url` / `collector_host_and_port` screens then reject
717/// it (fail closed).
718fn normalize_server(s: &str) -> String {
719 let s = s.trim();
720 // Force a scheme so `url::Url::parse` can split host/port; force https so we
721 // never speak plaintext to a collector (the wrapped AES key would leak).
722 let with_scheme = if let Some(rest) = s.strip_prefix("http://") {
723 format!("https://{rest}")
724 } else if s.starts_with("https://") {
725 s.to_string()
726 } else {
727 format!("https://{s}")
728 };
729 match url::Url::parse(&with_scheme) {
730 Ok(url) => match url.host_str() {
731 // `host_str()` already excludes userinfo/path; `port()` is `None`
732 // for the scheme default (443), which we then omit.
733 Some(host) => match url.port() {
734 Some(port) => format!("https://{host}:{port}"),
735 None => format!("https://{host}"),
736 },
737 None => with_scheme.trim_end_matches('/').to_string(),
738 },
739 Err(_invalid_collector) => with_scheme.trim_end_matches('/').to_string(),
740 }
741}