keyhog_sources/web.rs
1//! Web content source: scan JavaScript, source maps, and WASM binaries at URLs.
2//!
3//! Fetches web content over HTTP(S) and produces [`Chunk`]s for the scanner.
4//! Handles three content types:
5//!
6//! - **JavaScript**: fetched as text, scanned directly for hardcoded secrets.
7//! - **Source maps**: fetched as JSON, each `sourcesContent` entry becomes a
8//! separate chunk tagged with its original filename.
9//! - **WASM binaries**: fetched as bytes, printable ASCII strings ≥ 8 chars are
10//! extracted (identical to `strings` CLI) and scanned as text.
11//!
12//! # Examples
13//!
14//! ```rust,no_run
15//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! use keyhog_sources::WebSource;
17//! use keyhog_core::Source;
18//!
19//! let source = WebSource::new(vec![
20//! "https://example.com/app.js".to_string(),
21//! "https://example.com/app.js.map".to_string(),
22//! "https://example.com/module.wasm".to_string(),
23//! ]);
24//!
25//! for chunk in source.chunks() {
26//! let chunk = chunk?;
27//! println!("{}: {} bytes", chunk.metadata.source_type, chunk.data.len());
28//! }
29//! # Ok(()) }
30//! ```
31
32use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
33
34mod ssrf;
35pub(crate) use ssrf::{
36 build_web_client, is_disallowed_ip, is_disallowed_web_host, redact_url, resolve_and_screen,
37};
38
39/// Minimum printable string length for WASM binary string extraction.
40const MIN_WASM_STRING_LEN: usize = 8;
41
42/// Maximum response body size to prevent OOM on malicious targets (10 MB).
43const MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
44
45/// WASM magic bytes: `\0asm`.
46const WASM_MAGIC: &[u8; 4] = b"\x00asm";
47
48/// Web content source that fetches JavaScript, source maps, and WASM from URLs.
49///
50/// URLs ending in `.wasm` are treated as binary and have strings extracted.
51/// URLs ending in `.map` are treated as source maps and have `sourcesContent`
52/// entries split into individual chunks. Everything else is treated as
53/// JavaScript text.
54pub struct WebSource {
55 urls: Vec<String>,
56 http: crate::http::HttpClientConfig,
57}
58
59impl WebSource {
60 /// Create a web source from a list of URLs to scan.
61 ///
62 /// # Examples
63 ///
64 /// ```rust
65 /// use keyhog_sources::WebSource;
66 /// use keyhog_core::Source;
67 ///
68 /// let source = WebSource::new(vec!["https://example.com/app.js".into()]);
69 /// assert_eq!(source.name(), "web");
70 /// ```
71 pub fn new(urls: Vec<String>) -> Self {
72 Self {
73 urls,
74 http: crate::http::HttpClientConfig {
75 ua_suffix: Some("web".into()),
76 ..Default::default()
77 },
78 }
79 }
80
81 /// Create a web source from a single URL.
82 ///
83 /// # Examples
84 ///
85 /// ```rust
86 /// use keyhog_sources::WebSource;
87 /// use keyhog_core::Source;
88 ///
89 /// let source = WebSource::from_url("https://example.com/app.js");
90 /// assert_eq!(source.name(), "web");
91 /// ```
92 pub fn from_url(url: &str) -> Self {
93 Self::new(vec![url.to_string()])
94 }
95
96 /// Override the default HTTP policy (proxy, insecure-TLS,
97 /// timeout). Construct from `HttpClientConfig` directly when the
98 /// caller already has CLI-derived flags to thread through.
99 pub fn with_http_config(mut self, http: crate::http::HttpClientConfig) -> Self {
100 // Preserve the per-source UA suffix so the operator's proxy
101 // logs still tag this traffic as `keyhog/<ver> (web)`.
102 let mut http = http;
103 if http.ua_suffix.is_none() {
104 http.ua_suffix = Some("web".into());
105 }
106 self.http = http;
107 self
108 }
109
110 /// Fetch all URLs and produce chunks.
111 ///
112 /// Uses `reqwest::blocking` directly; the blocking client internally manages
113 /// its own background runtime, so no dedicated thread wrapper is required.
114 ///
115 /// Each URL gets its own client built via [`build_web_client`] so the
116 /// host can be DNS-resolved and pinned (DNS-rebinding defense); the
117 /// custom redirect policy re-validates every hop (redirect-to-internal
118 /// defense). Both gates mirror the verifier's `resolved_client_for_url`.
119 fn fetch_all(&self) -> Vec<Result<Chunk, SourceError>> {
120 let proxy_in_use = matches!(
121 self.http.effective_proxy().as_deref(),
122 Some(p) if !matches!(p, "off" | "none" | "")
123 );
124
125 let mut results = Vec::new();
126
127 for url in &self.urls {
128 // SSRF defense (host pre-filter): the verifier already has this
129 // gate via bogon for live verifications; WebSource was the
130 // missing surface. Without it,
131 // `WebSource::new(vec!["http://169.254.169.254/latest/meta-data/iam/..."])`
132 // would fetch the cloud metadata endpoint and extract IAM creds.
133 if is_disallowed_web_host(url) {
134 let safe_url = redact_url(url);
135 results.push(Err(SourceError::Other(format!(
136 "refusing to fetch {safe_url}: host resolves to a private / \
137 loopback / link-local / metadata-service address - \
138 WebSource only fetches public URLs"
139 ))));
140 continue;
141 }
142
143 let client = match build_web_client(&self.http, url, proxy_in_use) {
144 Ok(c) => c,
145 Err(e) => {
146 results.push(Err(e));
147 continue;
148 }
149 };
150
151 let chunks = fetch_url(&client, url);
152 results.extend(chunks);
153 }
154
155 results
156 }
157}
158
159impl Source for WebSource {
160 fn name(&self) -> &str {
161 "web"
162 }
163
164 fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
165 // `reqwest::blocking` must run off the CLI's `#[tokio::main]` thread:
166 // dropping its internal runtime inside an async context aborts the
167 // process. `fetch_all` is eager, so run it on a scoped std thread that
168 // carries no ambient tokio runtime.
169 let all = std::thread::scope(|s| {
170 s.spawn(|| self.fetch_all()).join().unwrap_or_else(|_| {
171 vec![Err(SourceError::Other(
172 "web fetch thread panicked".to_string(),
173 ))]
174 })
175 });
176 Box::new(all.into_iter())
177 }
178 fn as_any(&self) -> &dyn std::any::Any {
179 self
180 }
181}
182
183/// Fetch a single URL and produce one or more chunks based on content type.
184///
185/// The caller (`fetch_all`) has already screened `url` with
186/// `is_disallowed_web_host` and built `client` through `build_web_client`,
187/// which pins the resolved (screened) IP and installs the per-hop
188/// SSRF-revalidating redirect policy. The pre-filter is repeated here as a
189/// cheap defense-in-depth guard so this helper stays safe even if a future
190/// caller hands it a client that skipped `build_web_client`.
191fn fetch_url(client: &reqwest::blocking::Client, url: &str) -> Vec<Result<Chunk, SourceError>> {
192 // SSRF defense (host pre-filter): the verifier already has this gate via
193 // bogon for live verifications; WebSource was the missing surface.
194 // Without it,
195 // `WebSource::new(vec!["http://169.254.169.254/latest/meta-data/iam/..."])`
196 // would fetch the cloud metadata endpoint and extract IAM credentials.
197 // The redirect-target and DNS-rebinding bypasses of this gate are closed
198 // in `build_web_client`. Kimi sources-audit web-source SSRF finding.
199 if is_disallowed_web_host(url) {
200 let safe_url = redact_url(url);
201 return vec![Err(SourceError::Other(format!(
202 "refusing to fetch {safe_url}: host resolves to a private / \
203 loopback / link-local / metadata-service address - \
204 WebSource only fetches public URLs"
205 )))];
206 }
207
208 let resp = match client.get(url).send() {
209 Ok(r) => r,
210 Err(e) => {
211 let safe_url = redact_url(url);
212 return vec![Err(SourceError::Other(format!(
213 "failed to fetch {safe_url}: {e}"
214 )))];
215 }
216 };
217
218 let status = resp.status().as_u16();
219 if status != 200 {
220 let safe_url = redact_url(url);
221 tracing::warn!(url = %safe_url, status, "non-200 response, skipping");
222 return Vec::new();
223 }
224
225 // Route by URL extension
226 let lower = url.to_lowercase();
227 if lower.ends_with(".wasm") {
228 handle_wasm(resp, url)
229 } else if lower.ends_with(".map") || lower.contains(".map?") {
230 handle_sourcemap(resp, url)
231 } else {
232 handle_js(resp, url)
233 }
234}
235
236/// Handle a JavaScript file: return the full text as a single chunk.
237fn handle_js(resp: reqwest::blocking::Response, url: &str) -> Vec<Result<Chunk, SourceError>> {
238 match read_text_response(resp) {
239 Ok(body) => vec![Ok(Chunk {
240 data: body.into(),
241 metadata: ChunkMetadata {
242 base_offset: 0,
243 base_line: 0,
244 source_type: "web:js".to_string(),
245 path: Some(url.to_string()),
246 commit: None,
247 author: None,
248 date: None,
249 mtime_ns: None,
250 size_bytes: None,
251 },
252 })],
253 Err(e) => vec![Err(e)],
254 }
255}
256
257/// Handle a source map: parse JSON and emit each `sourcesContent` entry
258/// as a separate chunk tagged with the original filename.
259fn handle_sourcemap(
260 resp: reqwest::blocking::Response,
261 url: &str,
262) -> Vec<Result<Chunk, SourceError>> {
263 let body = match read_text_response(resp) {
264 Ok(b) => b,
265 Err(e) => return vec![Err(e)],
266 };
267
268 let map: serde_json::Value = match serde_json::from_str(&body) {
269 Ok(v) => v,
270 Err(e) => {
271 tracing::warn!(url = %redact_url(url), err = %e, "failed to parse source map JSON");
272 // Fall back to treating it as plain JS text
273 return vec![Ok(Chunk {
274 data: body.into(),
275 metadata: ChunkMetadata {
276 base_offset: 0,
277 base_line: 0,
278 source_type: "web:sourcemap:raw".to_string(),
279 path: Some(url.to_string()),
280 commit: None,
281 author: None,
282 date: None,
283 mtime_ns: None,
284 size_bytes: None,
285 },
286 })];
287 }
288 };
289
290 let sources: Vec<String> = map["sources"]
291 .as_array()
292 .unwrap_or(&vec![])
293 .iter()
294 .filter_map(|v| v.as_str().map(String::from))
295 .collect();
296
297 let contents: Vec<Option<String>> = map["sourcesContent"]
298 .as_array()
299 .map(|arr| arr.iter().map(|v| v.as_str().map(String::from)).collect())
300 .unwrap_or_default();
301
302 let mut chunks = Vec::new();
303
304 for (i, content) in contents.iter().enumerate() {
305 if let Some(code) = content {
306 if code.is_empty() {
307 continue;
308 }
309 let source_name = sources
310 .get(i)
311 .cloned()
312 .unwrap_or_else(|| format!("source_{i}"));
313 chunks.push(Ok(Chunk {
314 data: code.clone().into(),
315 metadata: ChunkMetadata {
316 base_offset: 0,
317 base_line: 0,
318 source_type: "web:sourcemap".to_string(),
319 path: Some(format!("{url}!{source_name}")),
320 commit: None,
321 author: None,
322 date: None,
323 mtime_ns: None,
324 size_bytes: None,
325 },
326 }));
327 }
328 }
329
330 // If no sourcesContent, treat the raw map as scannable text
331 if chunks.is_empty() {
332 chunks.push(Ok(Chunk {
333 data: body.into(),
334 metadata: ChunkMetadata {
335 base_offset: 0,
336 base_line: 0,
337 source_type: "web:sourcemap:raw".to_string(),
338 path: Some(url.to_string()),
339 commit: None,
340 author: None,
341 date: None,
342 mtime_ns: None,
343 size_bytes: None,
344 },
345 }));
346 }
347
348 chunks
349}
350
351/// Handle a WASM binary: extract printable strings and scan as text.
352fn handle_wasm(resp: reqwest::blocking::Response, url: &str) -> Vec<Result<Chunk, SourceError>> {
353 let bytes = match read_bytes_response(resp) {
354 Ok(b) => b,
355 Err(e) => return vec![Err(e)],
356 };
357
358 // Verify WASM magic bytes
359 if bytes.len() < 4 || &bytes[..4] != WASM_MAGIC {
360 tracing::warn!(url = %redact_url(url), "not a valid WASM file (wrong magic bytes)");
361 return Vec::new();
362 }
363
364 let strings = crate::strings::extract_printable_strings(&bytes, MIN_WASM_STRING_LEN);
365 if strings.is_empty() {
366 return Vec::new();
367 }
368
369 vec![Ok(Chunk {
370 data: keyhog_core::SensitiveString::join(&strings, "\n"),
371 metadata: ChunkMetadata {
372 base_offset: 0,
373 base_line: 0,
374 source_type: "web:wasm".to_string(),
375 path: Some(url.to_string()),
376 commit: None,
377 author: None,
378 date: None,
379 mtime_ns: None,
380 size_bytes: None,
381 },
382 })]
383}
384
385/// Read an HTTP response body as text, capping at `MAX_RESPONSE_BYTES`.
386///
387/// Pre-flight Content-Length and streamed cap-aware copy. The previous
388/// version called `.text()` (which auto-decompresses gzip/deflate to
389/// completion) before checking the size - a 1 MB gzip bomb expanding to
390/// 1+ GB would OOM before this check fired. See `audit release-2026-04-26
391/// web.rs:287-301`.
392fn read_text_response(resp: reqwest::blocking::Response) -> Result<String, SourceError> {
393 let bytes = read_bytes_response(resp)?;
394 String::from_utf8(bytes).map_err(|e| SourceError::Other(format!("non-UTF-8 response: {e}")))
395}
396
397/// Read an HTTP response body as bytes, capping at `MAX_RESPONSE_BYTES`
398/// BEFORE decompression to defeat gzip-bomb DoS.
399fn read_bytes_response(resp: reqwest::blocking::Response) -> Result<Vec<u8>, SourceError> {
400 use std::io::Read;
401 let url = resp.url().to_string();
402 let safe_url = redact_url(&url);
403
404 if let Some(len) = resp.content_length() {
405 if len as usize > MAX_RESPONSE_BYTES {
406 return Err(SourceError::Other(format!(
407 "response from {safe_url} declares {len} bytes (> {} MB limit)",
408 MAX_RESPONSE_BYTES / (1024 * 1024)
409 )));
410 }
411 }
412
413 // Stream into a bounded buffer; abort the moment we exceed the cap.
414 let mut buf = Vec::with_capacity(MAX_RESPONSE_BYTES.min(64 * 1024));
415 let mut taken = resp.take(MAX_RESPONSE_BYTES as u64 + 1);
416 taken
417 .read_to_end(&mut buf)
418 .map_err(|e| SourceError::Other(format!("failed to read bytes from {safe_url}: {e}")))?;
419 if buf.len() > MAX_RESPONSE_BYTES {
420 return Err(SourceError::Other(format!(
421 "response from {safe_url} exceeds {} MB limit",
422 MAX_RESPONSE_BYTES / (1024 * 1024)
423 )));
424 }
425
426 Ok(buf)
427}