use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
mod ssrf;
pub(crate) use ssrf::{
build_web_client, is_disallowed_ip, is_disallowed_web_host, redact_url, resolve_and_screen,
};
const MIN_WASM_STRING_LEN: usize = 8;
const MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
const WASM_MAGIC: &[u8; 4] = b"\x00asm";
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 proxy_in_use = matches!(
self.http.effective_proxy().as_deref(),
Some(p) if !matches!(p, "off" | "none" | "")
);
let mut results = Vec::new();
for url in &self.urls {
if is_disallowed_web_host(url) {
let safe_url = redact_url(url);
results.push(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"
))));
continue;
}
let client = match build_web_client(&self.http, url, proxy_in_use) {
Ok(c) => c,
Err(e) => {
results.push(Err(e));
continue;
}
};
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,
base_line: 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,
base_line: 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,
base_line: 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,
base_line: 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,
base_line: 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)
}