use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
const MIN_WASM_STRING_LEN: usize = 8;
const MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
const WASM_MAGIC: &[u8; 4] = b"\x00asm";
fn redact_url(url: &str) -> std::borrow::Cow<'_, str> {
let scheme_end = match url.find("://") {
Some(idx) => idx + 3,
None => return std::borrow::Cow::Borrowed(url),
};
let after_scheme = &url[scheme_end..];
let authority_end = after_scheme
.find(['/', '?', '#'])
.unwrap_or(after_scheme.len());
let authority = &after_scheme[..authority_end];
let Some(at_offset) = authority.find('@') else {
return std::borrow::Cow::Borrowed(url);
};
let mut out = String::with_capacity(url.len());
out.push_str(&url[..scheme_end]);
out.push_str("***@");
out.push_str(&after_scheme[at_offset + 1..]);
std::borrow::Cow::Owned(out)
}
fn is_disallowed_web_host(url: &str) -> bool {
let parsed = match reqwest::Url::parse(url) {
Ok(u) => u,
Err(_) => return true, };
let Some(host) = parsed.host() else {
return true; };
match host {
url::Host::Ipv4(ip) => is_disallowed_ipv4(ip),
url::Host::Ipv6(ip) => is_disallowed_ipv6(ip),
url::Host::Domain(d) => {
let lower = d.to_ascii_lowercase();
lower == "localhost"
|| lower.ends_with(".local")
|| lower.ends_with(".internal")
|| lower.ends_with(".localdomain")
|| lower == "metadata.google.internal"
}
}
}
fn is_disallowed_ipv4(ip: std::net::Ipv4Addr) -> bool {
ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_broadcast()
|| ip.is_unspecified()
}
fn is_disallowed_ipv6(ip: std::net::Ipv6Addr) -> bool {
if let Some(v4) = ip.to_ipv4_mapped() {
return is_disallowed_ipv4(v4);
}
ip.is_loopback() || ip.is_multicast() || ip.is_unspecified()
|| ip.segments()[0] & 0xfe00 == 0xfc00 || ip.segments()[0] & 0xffc0 == 0xfe80 }
#[cfg(test)]
mod web_host_filter_tests {
use super::is_disallowed_web_host;
#[test]
fn rejects_cloud_metadata_endpoints() {
assert!(is_disallowed_web_host(
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
));
assert!(is_disallowed_web_host(
"http://metadata.google.internal/computeMetadata/v1/"
));
}
#[test]
fn rejects_loopback_and_private() {
assert!(is_disallowed_web_host("http://127.0.0.1/"));
assert!(is_disallowed_web_host("http://10.0.0.5/"));
assert!(is_disallowed_web_host("http://192.168.1.1/"));
assert!(is_disallowed_web_host("http://172.16.0.5/"));
assert!(is_disallowed_web_host("http://[::1]/"));
assert!(is_disallowed_web_host("http://localhost/"));
assert!(is_disallowed_web_host("http://machine.local/"));
assert!(is_disallowed_web_host("http://svc.internal/api"));
}
#[test]
fn rejects_malformed_or_hostless() {
assert!(is_disallowed_web_host("not a url"));
assert!(is_disallowed_web_host("file:///etc/passwd"));
}
#[test]
fn accepts_real_public_hosts() {
assert!(!is_disallowed_web_host("https://example.com/"));
assert!(!is_disallowed_web_host("https://cdn.jsdelivr.net/app.js"));
assert!(!is_disallowed_web_host(
"https://api.github.com/repos/foo/bar"
));
}
#[test]
fn web_source_threads_proxy_into_blocking_client_builder() {
let cfg_ok = crate::http::HttpClientConfig {
proxy: Some("http://127.0.0.1:8080".into()),
..Default::default()
};
assert!(
crate::http::blocking_client_builder(&cfg_ok)
.and_then(|b| b.build().map_err(|e| e.to_string()))
.is_ok(),
"valid proxy URL must build a client; if this fails, the source-side \
proxy plumbing is broken before it ever leaves WebSource"
);
let cfg_bad = crate::http::HttpClientConfig {
proxy: Some("not a url".into()),
..Default::default()
};
assert!(
crate::http::blocking_client_builder(&cfg_bad).is_err(),
"malformed proxy URL must be rejected at builder time, not silently \
skipped. If this passes, `--proxy` validation is gone and bad URLs \
reach reqwest as a no-op default."
);
}
#[test]
fn rejects_ipv4_mapped_ipv6_loopback_and_private() {
assert!(
is_disallowed_web_host("http://[::ffff:127.0.0.1]/"),
"::ffff:127.0.0.1 must route to v4 loopback check"
);
assert!(
is_disallowed_web_host("http://[::ffff:10.0.0.1]/"),
"::ffff:10.0.0.1 must route to v4 private check"
);
assert!(
is_disallowed_web_host("http://[::ffff:169.254.169.254]/"),
"::ffff:169.254.169.254 (cloud-metadata via v6-mapped form) must block"
);
assert!(
is_disallowed_web_host("http://[::ffff:192.168.1.1]/"),
"::ffff:192.168.1.1 (private via v6-mapped form) must block"
);
assert!(
is_disallowed_web_host("http://[::ffff:172.16.0.5]/"),
"::ffff:172.16.0.5 (private via v6-mapped form) must block"
);
}
}
#[cfg(test)]
mod redact_url_tests {
use super::redact_url;
#[test]
fn passes_through_urls_without_userinfo() {
for ok in &[
"https://example.com/path",
"http://example.com:8080/p?q=1",
"https://example.com/path/with/@symbol/in/it",
] {
assert_eq!(redact_url(ok), *ok, "unchanged for {ok:?}");
}
}
#[test]
fn strips_userinfo() {
assert_eq!(
redact_url("https://user:SECRET@host/path"),
"https://***@host/path"
);
assert_eq!(
redact_url("https://user@host/path?q=1"),
"https://***@host/path?q=1"
);
assert_eq!(
redact_url("http://x:y@example.com:8080/p#frag"),
"http://***@example.com:8080/p#frag"
);
}
#[test]
fn does_not_confuse_path_at_with_userinfo() {
let url = "https://example.com/orgs/foo/users/@me";
assert_eq!(redact_url(url), url);
}
}
pub struct WebSource {
urls: Vec<String>,
http: crate::http::HttpClientConfig,
}
impl WebSource {
pub fn new(urls: Vec<String>) -> Self {
Self {
urls,
http: crate::http::HttpClientConfig {
ua_suffix: Some("web".into()),
..Default::default()
},
}
}
pub fn from_url(url: &str) -> Self {
Self::new(vec![url.to_string()])
}
pub fn with_http_config(mut self, http: crate::http::HttpClientConfig) -> Self {
let mut http = http;
if http.ua_suffix.is_none() {
http.ua_suffix = Some("web".into());
}
self.http = http;
self
}
fn fetch_all(&self) -> Vec<Result<Chunk, SourceError>> {
let client = match crate::http::blocking_client_builder(&self.http) {
Ok(b) => b
.timeout(crate::timeouts::HTTP_REQUEST)
.build()
.map_err(|e| SourceError::Other(format!("failed to build HTTP client: {e}"))),
Err(e) => Err(SourceError::Other(e)),
};
let client = match client {
Ok(c) => c,
Err(e) => return vec![Err(e)],
};
let mut results = Vec::new();
for url in &self.urls {
let chunks = fetch_url(&client, url);
results.extend(chunks);
}
results
}
}
impl Source for WebSource {
fn name(&self) -> &str {
"web"
}
fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
Box::new(self.fetch_all().into_iter())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
fn fetch_url(client: &reqwest::blocking::Client, url: &str) -> Vec<Result<Chunk, SourceError>> {
if is_disallowed_web_host(url) {
let safe_url = redact_url(url);
return vec![Err(SourceError::Other(format!(
"refusing to fetch {safe_url}: host resolves to a private / \
loopback / link-local / metadata-service address - \
WebSource only fetches public URLs"
)))];
}
let resp = match client.get(url).send() {
Ok(r) => r,
Err(e) => {
let safe_url = redact_url(url);
return vec![Err(SourceError::Other(format!(
"failed to fetch {safe_url}: {e}"
)))];
}
};
let status = resp.status().as_u16();
if status != 200 {
let safe_url = redact_url(url);
tracing::warn!(url = %safe_url, status, "non-200 response, skipping");
return Vec::new();
}
let lower = url.to_lowercase();
if lower.ends_with(".wasm") {
handle_wasm(resp, url)
} else if lower.ends_with(".map") || lower.contains(".map?") {
handle_sourcemap(resp, url)
} else {
handle_js(resp, url)
}
}
fn handle_js(resp: reqwest::blocking::Response, url: &str) -> Vec<Result<Chunk, SourceError>> {
match read_text_response(resp) {
Ok(body) => vec![Ok(Chunk {
data: body.into(),
metadata: ChunkMetadata {
base_offset: 0,
source_type: "web:js".to_string(),
path: Some(url.to_string()),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
},
})],
Err(e) => vec![Err(e)],
}
}
fn handle_sourcemap(
resp: reqwest::blocking::Response,
url: &str,
) -> Vec<Result<Chunk, SourceError>> {
let body = match read_text_response(resp) {
Ok(b) => b,
Err(e) => return vec![Err(e)],
};
let map: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
tracing::warn!(url = %redact_url(url), err = %e, "failed to parse source map JSON");
return vec![Ok(Chunk {
data: body.into(),
metadata: ChunkMetadata {
base_offset: 0,
source_type: "web:sourcemap:raw".to_string(),
path: Some(url.to_string()),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
},
})];
}
};
let sources: Vec<String> = map["sources"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
let contents: Vec<Option<String>> = map["sourcesContent"]
.as_array()
.map(|arr| arr.iter().map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let mut chunks = Vec::new();
for (i, content) in contents.iter().enumerate() {
if let Some(code) = content {
if code.is_empty() {
continue;
}
let source_name = sources
.get(i)
.cloned()
.unwrap_or_else(|| format!("source_{i}"));
chunks.push(Ok(Chunk {
data: code.clone().into(),
metadata: ChunkMetadata {
base_offset: 0,
source_type: "web:sourcemap".to_string(),
path: Some(format!("{url}!{source_name}")),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
},
}));
}
}
if chunks.is_empty() {
chunks.push(Ok(Chunk {
data: body.into(),
metadata: ChunkMetadata {
base_offset: 0,
source_type: "web:sourcemap:raw".to_string(),
path: Some(url.to_string()),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
},
}));
}
chunks
}
fn handle_wasm(resp: reqwest::blocking::Response, url: &str) -> Vec<Result<Chunk, SourceError>> {
let bytes = match read_bytes_response(resp) {
Ok(b) => b,
Err(e) => return vec![Err(e)],
};
if bytes.len() < 4 || &bytes[..4] != WASM_MAGIC {
tracing::warn!(url = %redact_url(url), "not a valid WASM file (wrong magic bytes)");
return Vec::new();
}
let strings = crate::strings::extract_printable_strings(&bytes, MIN_WASM_STRING_LEN);
if strings.is_empty() {
return Vec::new();
}
vec![Ok(Chunk {
data: keyhog_core::SensitiveString::join(&strings, "\n"),
metadata: ChunkMetadata {
base_offset: 0,
source_type: "web:wasm".to_string(),
path: Some(url.to_string()),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
},
})]
}
fn read_text_response(resp: reqwest::blocking::Response) -> Result<String, SourceError> {
let bytes = read_bytes_response(resp)?;
String::from_utf8(bytes).map_err(|e| SourceError::Other(format!("non-UTF-8 response: {e}")))
}
fn read_bytes_response(resp: reqwest::blocking::Response) -> Result<Vec<u8>, SourceError> {
use std::io::Read;
let url = resp.url().to_string();
let safe_url = redact_url(&url);
if let Some(len) = resp.content_length() {
if len as usize > MAX_RESPONSE_BYTES {
return Err(SourceError::Other(format!(
"response from {safe_url} declares {len} bytes (> {} MB limit)",
MAX_RESPONSE_BYTES / (1024 * 1024)
)));
}
}
let mut buf = Vec::with_capacity(MAX_RESPONSE_BYTES.min(64 * 1024));
let mut taken = resp.take(MAX_RESPONSE_BYTES as u64 + 1);
taken
.read_to_end(&mut buf)
.map_err(|e| SourceError::Other(format!("failed to read bytes from {safe_url}: {e}")))?;
if buf.len() > MAX_RESPONSE_BYTES {
return Err(SourceError::Other(format!(
"response from {safe_url} exceeds {} MB limit",
MAX_RESPONSE_BYTES / (1024 * 1024)
)));
}
Ok(buf)
}