use html5ever::{parse_document, tendril::TendrilSink};
use markup5ever_rcdom::{Handle, NodeData, RcDom};
use serde::{Deserialize, Serialize};
use tracing::debug;
use url::Url;
const MAX_ASSETS_CHECKED: usize = 20;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SourceMapLeakAudit {
pub assets_checked: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub leaks: Vec<SourceMapLeak>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceMapLeak {
pub asset_url: String,
pub map_url: String,
}
pub async fn audit_source_maps(page_url: &str) -> SourceMapLeakAudit {
let client = match reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(5))
.timeout(std::time::Duration::from_secs(8))
.user_agent("auditmysite-probe/1.0")
.build()
{
Ok(client) => client,
Err(_) => return SourceMapLeakAudit::default(),
};
let Ok(page_resp) = client.get(page_url).send().await else {
return SourceMapLeakAudit::default();
};
let Ok(html) = page_resp.text().await else {
return SourceMapLeakAudit::default();
};
let asset_urls = extract_asset_urls(page_url, &html);
if asset_urls.is_empty() {
return SourceMapLeakAudit::default();
}
let mut audit = SourceMapLeakAudit::default();
for asset_url in asset_urls.iter().take(MAX_ASSETS_CHECKED) {
let Ok(resp) = client.get(asset_url).send().await else {
continue;
};
if !resp.status().is_success() {
continue;
}
let Ok(body) = resp.text().await else {
continue;
};
audit.assets_checked += 1;
let Some(map_ref) = extract_source_mapping_url(&body) else {
continue;
};
if map_ref.starts_with("data:") {
continue;
}
let Some(map_url) = Url::parse(asset_url)
.ok()
.and_then(|base| base.join(&map_ref).ok())
else {
continue;
};
if let Ok(map_resp) = client.head(map_url.as_str()).send().await {
if map_resp.status().is_success() {
debug!("Public source map leak: {} -> {}", asset_url, map_url);
audit.leaks.push(SourceMapLeak {
asset_url: asset_url.clone(),
map_url: map_url.to_string(),
});
}
}
}
audit
}
fn extract_asset_urls(page_url: &str, html: &str) -> Vec<String> {
let dom: RcDom = parse_document(RcDom::default(), Default::default()).one(html);
let mut raw = Vec::new();
collect_asset_hrefs(&dom.document, &mut raw);
let Ok(base) = Url::parse(page_url) else {
return Vec::new();
};
let mut seen = std::collections::BTreeSet::new();
raw.into_iter()
.filter_map(|href| {
let resolved = base.join(&href).ok()?.to_string();
seen.insert(resolved.clone()).then_some(resolved)
})
.collect()
}
fn collect_asset_hrefs(handle: &Handle, out: &mut Vec<String>) {
if let NodeData::Element { name, attrs, .. } = &handle.data {
let tag = name.local.as_ref();
let attrs = attrs.borrow();
let attr = |attr_name: &str| {
attrs
.iter()
.find(|a| a.name.local.as_ref().eq_ignore_ascii_case(attr_name))
.map(|a| a.value.to_string())
};
if tag.eq_ignore_ascii_case("script") {
if let Some(src) = attr("src") {
if !src.trim().is_empty() {
out.push(src);
}
}
} else if tag.eq_ignore_ascii_case("link") {
let is_stylesheet = attr("rel")
.map(|r| {
r.to_ascii_lowercase()
.split_whitespace()
.any(|t| t == "stylesheet")
})
.unwrap_or(false);
if is_stylesheet {
if let Some(href) = attr("href") {
if !href.trim().is_empty() {
out.push(href);
}
}
}
}
}
for child in handle.children.borrow().iter() {
collect_asset_hrefs(child, out);
}
}
fn extract_source_mapping_url(body: &str) -> Option<String> {
body.lines().rev().take(5).find_map(|line| {
let line = line.trim();
let marker = "sourceMappingURL=";
let idx = line.find(marker)?;
let rest = &line[idx + marker.len()..];
let value = rest.trim().trim_end_matches("*/").trim().to_string();
(!value.is_empty()).then_some(value)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_asset_urls_finds_scripts_and_stylesheets() {
let html = r#"
<html><head>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="/favicon.ico">
<script src="/js/app.js"></script>
<script>console.log("inline")</script>
</head></html>
"#;
let urls = extract_asset_urls("https://example.com/page", html);
assert_eq!(
urls,
vec![
"https://example.com/css/app.css",
"https://example.com/js/app.js",
]
);
}
#[test]
fn extract_asset_urls_dedupes() {
let html = r#"<script src="/js/app.js"></script><script src="/js/app.js"></script>"#;
let urls = extract_asset_urls("https://example.com/page", html);
assert_eq!(urls, vec!["https://example.com/js/app.js"]);
}
#[test]
fn extract_source_mapping_url_finds_js_comment() {
let body = "console.log(1);\n//# sourceMappingURL=app.js.map\n";
assert_eq!(
extract_source_mapping_url(body),
Some("app.js.map".to_string())
);
}
#[test]
fn extract_source_mapping_url_finds_css_comment() {
let body = "body{color:red}\n/*# sourceMappingURL=app.css.map */\n";
assert_eq!(
extract_source_mapping_url(body),
Some("app.css.map".to_string())
);
}
#[test]
fn extract_source_mapping_url_absent_returns_none() {
let body = "console.log(1);\n";
assert_eq!(extract_source_mapping_url(body), None);
}
#[test]
fn extract_source_mapping_url_ignores_inline_data_uri_at_call_site() {
let body = "//# sourceMappingURL=data:application/json;base64,abc123\n";
assert!(extract_source_mapping_url(body)
.unwrap()
.starts_with("data:"));
}
}