Skip to main content

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}