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}