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