Skip to main content

keyhog_sources/
http.rs

1//! Shared HTTP client builder for every source / verifier site that
2//! goes over the network.
3//!
4//! Why this lives in one module
5//! ----------------------------
6//! `web.rs`, `github_org.rs`, the verifier's `verify/request.rs` and
7//! `verify/mod.rs`, and the slack source all build their own
8//! `reqwest::Client` directly. Without a shared builder, an operator
9//! who puts `HTTP_PROXY=http://burp:8080` into their environment would
10//! discover that *some* sites honor it (the ones that don't call
11//! `.no_proxy()`) and *others* don't, with no good signal as to why.
12//! Worse, adding `--proxy` to one site without the others would mean
13//! the verifier silently bypasses the proxy that the scan sources are
14//! routed through - so leaked-then-verified findings still leak the
15//! credential straight to the upstream API.
16//!
17//! [`HttpClientConfig`] is the single point of policy and
18//! [`blocking_client_builder`] / [`async_client_builder`] are the
19//! single construction sites. Every HTTP call in the binary flows
20//! through one of them.
21//!
22//! Policy summary
23//! --------------
24//! * **Proxy resolution.** Explicit config (`HttpClientConfig::proxy`,
25//!   set by the `--proxy` CLI flag) wins. Otherwise the
26//!   `KEYHOG_PROXY` env var, then reqwest's built-in handling of
27//!   `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY`. To
28//!   disable proxying entirely (e.g. an air-gapped scan that must not
29//!   leak), set `--proxy off` or `KEYHOG_PROXY=off`.
30//! * **TLS verification.** On by default. `--insecure` (or
31//!   `KEYHOG_INSECURE_TLS=1`) accepts any certificate - needed for
32//!   Burp / mitmproxy interception where the proxy MITMs HTTPS with a
33//!   self-signed CA.
34//! * **Body-bomb defenses.** Auto-decompression OFF (the per-site
35//!   chunk handlers opt in where they need it). 5-hop redirect cap.
36//!   30 s connect / per-request timeout default.
37//! * **User-Agent.** `keyhog/<version>` so operators can spot keyhog
38//!   traffic in their proxy logs without grepping for "reqwest".
39
40use std::time::Duration;
41
42/// Single source of truth for outbound HTTP policy.
43#[derive(Debug, Clone, Default)]
44pub struct HttpClientConfig {
45    /// Explicit proxy URL. Overrides env-var detection. Accepts
46    /// `http://`, `https://`, `socks5://` schemes (forwarded to
47    /// reqwest unchanged). The literal string `"off"` disables
48    /// proxying entirely - including env-var inheritance.
49    pub proxy: Option<String>,
50    /// Accept invalid / self-signed TLS certs (Burp CA, mitmproxy CA).
51    /// Off by default.
52    pub insecure_tls: bool,
53    /// Optional per-request timeout override. `None` falls back to
54    /// the 30-second default below.
55    pub timeout: Option<Duration>,
56    /// User-Agent suffix appended after `keyhog/<version>`. Lets a
57    /// per-source caller add its own identifier (e.g. `web`,
58    /// `github-org`) without forcing every site to spell out the full
59    /// version string.
60    pub ua_suffix: Option<String>,
61}
62
63impl HttpClientConfig {
64    /// Resolve proxy from env vars when no explicit value was set.
65    /// Returns `Some("off")` if the operator disabled proxying.
66    pub fn effective_proxy(&self) -> Option<String> {
67        if let Some(p) = &self.proxy {
68            return Some(p.clone());
69        }
70        if let Ok(p) = std::env::var("KEYHOG_PROXY") {
71            if !p.is_empty() {
72                return Some(p);
73            }
74        }
75        None
76    }
77
78    /// Resolve insecure-TLS from env when not set explicitly.
79    pub fn effective_insecure_tls(&self) -> bool {
80        if self.insecure_tls {
81            return true;
82        }
83        matches!(
84            std::env::var("KEYHOG_INSECURE_TLS").as_deref(),
85            Ok("1") | Ok("true") | Ok("TRUE")
86        )
87    }
88}
89
90const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
91const REDIRECT_LIMIT: usize = 5;
92
93pub(crate) fn user_agent(suffix: Option<&str>) -> String {
94    let base = concat!("keyhog/", env!("CARGO_PKG_VERSION"));
95    match suffix {
96        Some(s) if !s.is_empty() => format!("{base} ({s})"),
97        _ => base.to_string(),
98    }
99}
100
101/// Build a `reqwest::blocking::ClientBuilder` populated with the
102/// shared policy. Callers can chain extra builder methods (e.g.
103/// `.default_headers(...)`) before `.build()`.
104#[cfg(any(feature = "web", feature = "github", feature = "slack", feature = "s3"))]
105pub fn blocking_client_builder(
106    cfg: &HttpClientConfig,
107) -> Result<reqwest::blocking::ClientBuilder, String> {
108    let mut builder = reqwest::blocking::Client::builder()
109        .timeout(cfg.timeout.unwrap_or(DEFAULT_TIMEOUT))
110        .redirect(reqwest::redirect::Policy::limited(REDIRECT_LIMIT))
111        .user_agent(user_agent(cfg.ua_suffix.as_deref()))
112        .no_gzip()
113        .no_brotli()
114        .no_deflate()
115        .danger_accept_invalid_certs(cfg.effective_insecure_tls());
116
117    match cfg.effective_proxy().as_deref() {
118        Some("off") | Some("none") | Some("") => {
119            builder = builder.no_proxy();
120        }
121        Some(url) => {
122            let proxy = reqwest::Proxy::all(url)
123                .map_err(|e| format!("invalid --proxy / KEYHOG_PROXY URL {url:?}: {e}"))?;
124            builder = builder.proxy(proxy);
125        }
126        None => {
127            // reqwest auto-detects HTTPS_PROXY / HTTP_PROXY /
128            // ALL_PROXY / NO_PROXY by default; nothing to do.
129        }
130    }
131
132    Ok(builder)
133}
134
135/// Async sibling for the verifier's tokio-based call sites.
136#[cfg(any(feature = "web", feature = "github", feature = "slack", feature = "s3"))]
137pub fn async_client_builder(cfg: &HttpClientConfig) -> Result<reqwest::ClientBuilder, String> {
138    let mut builder = reqwest::Client::builder()
139        .timeout(cfg.timeout.unwrap_or(DEFAULT_TIMEOUT))
140        .redirect(reqwest::redirect::Policy::limited(REDIRECT_LIMIT))
141        .user_agent(user_agent(cfg.ua_suffix.as_deref()))
142        .danger_accept_invalid_certs(cfg.effective_insecure_tls());
143
144    match cfg.effective_proxy().as_deref() {
145        Some("off") | Some("none") | Some("") => {
146            builder = builder.no_proxy();
147        }
148        Some(url) => {
149            let proxy = reqwest::Proxy::all(url)
150                .map_err(|e| format!("invalid --proxy / KEYHOG_PROXY URL {url:?}: {e}"))?;
151            builder = builder.proxy(proxy);
152        }
153        None => {}
154    }
155
156    Ok(builder)
157}