Skip to main content

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}