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}