use csp_parse::{Keyword, SourceExpression, SourceList, parse_policy_list, parse_source_list};
use html5_parser::{Document, NodeId, NodeKind};
use crate::{Finding, Severity, SourceLocation};
const RULE_ID: &str = "csp.meta-enforcement";
pub(crate) fn findings(document: &Document) -> Vec<Finding> {
let policy_contents = collect_meta_csp_contents(document, document.root());
if policy_contents.is_empty() {
return Vec::new();
}
let script_blocked = policy_contents
.iter()
.any(|content| directive_blocks_inline(content, "script-src"));
let style_blocked = policy_contents
.iter()
.any(|content| directive_blocks_inline(content, "style-src"));
if !script_blocked && !style_blocked {
return Vec::new();
}
let mut findings = Vec::new();
collect_violations(
document,
document.root(),
script_blocked,
style_blocked,
&mut findings,
);
findings
}
fn collect_meta_csp_contents(document: &Document, id: NodeId) -> Vec<String> {
let mut contents = Vec::new();
collect_meta_csp_contents_into(document, id, &mut contents);
contents
}
fn collect_meta_csp_contents_into(document: &Document, id: NodeId, out: &mut Vec<String>) {
let node = document.node(id);
if let NodeKind::Element {
name, attributes, ..
} = &node.kind
&& name.eq_ignore_ascii_case("meta")
{
let is_csp = attributes.iter().any(|attribute| {
attribute.name.eq_ignore_ascii_case("http-equiv")
&& attribute
.value
.eq_ignore_ascii_case("content-security-policy")
});
if is_csp
&& let Some(content) = attributes
.iter()
.find(|attribute| attribute.name.eq_ignore_ascii_case("content"))
{
out.push(content.value.clone());
}
}
for child in document.children(id) {
collect_meta_csp_contents_into(document, child, out);
}
}
fn directive_blocks_inline(policy_content: &str, fetch_directive: &str) -> bool {
let policy_list = parse_policy_list(policy_content);
policy_list.policies.iter().any(|policy| {
let directive = policy
.directives
.iter()
.find(|directive| directive.name.eq_ignore_ascii_case(fetch_directive))
.or_else(|| {
policy
.directives
.iter()
.find(|directive| directive.name.eq_ignore_ascii_case("default-src"))
});
let Some(directive) = directive else {
return false;
};
let Some(raw_value) = &directive.raw_value else {
return true;
};
let source_list = parse_source_list(raw_value);
!source_list_allows_unsafe_inline(&source_list)
})
}
fn source_list_allows_unsafe_inline(source_list: &SourceList) -> bool {
match source_list {
SourceList::None => false,
SourceList::Sources(entries) => entries.iter().any(|entry| {
matches!(
entry.expression,
Some(SourceExpression::Keyword(Keyword::UnsafeInline))
)
}),
_ => false,
}
}
fn collect_violations(
document: &Document,
id: NodeId,
script_blocked: bool,
style_blocked: bool,
findings: &mut Vec<Finding>,
) {
let node = document.node(id);
if let NodeKind::Element {
name, attributes, ..
} = &node.kind
{
let location = node.position.map(|position| SourceLocation {
line: position.line,
column: position.column,
byte_offset: position.byte_offset,
});
if script_blocked {
if name.eq_ignore_ascii_case("script")
&& !attributes
.iter()
.any(|attribute| attribute.name.eq_ignore_ascii_case("src"))
{
findings.push(finding(
"Inline script violates Content Security Policy (meta tag): blocked by \
\"script-src\" directive (missing \"'unsafe-inline'\" or nonce/hash)."
.to_owned(),
location,
));
}
for attribute in attributes {
if attribute.name.len() > 2
&& attribute.name.as_bytes()[0..2].eq_ignore_ascii_case(b"on")
{
findings.push(finding(
format!(
"Event handler attribute \"{}\" violates Content Security Policy \
(meta tag): blocked by \"script-src\" directive.",
attribute.name
),
location,
));
}
}
}
if style_blocked {
if name.eq_ignore_ascii_case("style") {
findings.push(finding(
"Inline style violates Content Security Policy (meta tag): blocked by \
\"style-src\" directive (missing \"'unsafe-inline'\" or nonce/hash)."
.to_owned(),
location,
));
}
if attributes
.iter()
.any(|attribute| attribute.name.eq_ignore_ascii_case("style"))
{
findings.push(finding(
"The \"style\" attribute violates Content Security Policy (meta tag): \
blocked by \"style-src\" directive."
.to_owned(),
location,
));
}
}
}
for child in document.children(id) {
collect_violations(document, child, script_blocked, style_blocked, findings);
}
}
fn finding(message: String, location: Option<SourceLocation>) -> Finding {
Finding {
rule_id: RULE_ID.to_owned(),
severity: Severity::Warning,
message,
location,
}
}
#[cfg(test)]
mod tests {
fn csp_findings(html: &str) -> Vec<crate::Finding> {
crate::check(html)
.expect("HTML5 parsing should recover")
.findings
.into_iter()
.filter(|finding| finding.rule_id == super::RULE_ID)
.collect()
}
#[test]
fn no_meta_csp_is_clean() {
assert!(
csp_findings("<!doctype html><title>t</title><script>alert(1)</script>").is_empty()
);
}
#[test]
fn unsafe_inline_script_src_allows_inline_script() {
assert!(
csp_findings(
"<!doctype html><title>t</title>\
<meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'unsafe-inline'\">\
<script>alert(1)</script>"
)
.is_empty()
);
}
#[test]
fn inline_script_without_unsafe_inline_is_flagged() {
let findings = csp_findings(
"<!doctype html><title>t</title>\
<meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'self'\">\
<script>alert(1)</script>",
);
assert_eq!(findings.len(), 1);
}
#[test]
fn event_handler_attribute_is_flagged() {
let findings = csp_findings(
"<!doctype html><title>t</title>\
<meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'self'\">\
<body><button onclick=\"go()\">x</button></body>",
);
assert_eq!(findings.len(), 1);
}
#[test]
fn inline_style_element_is_flagged() {
let findings = csp_findings(
"<!doctype html><title>t</title>\
<meta http-equiv=\"Content-Security-Policy\" content=\"style-src 'self'\">\
<style>body{color:red}</style>",
);
assert_eq!(findings.len(), 1);
}
#[test]
fn style_attribute_is_flagged() {
let findings = csp_findings(
"<!doctype html><title>t</title>\
<meta http-equiv=\"Content-Security-Policy\" content=\"style-src 'self'\">\
<body><p style=\"color:red\">x</p></body>",
);
assert_eq!(findings.len(), 1);
}
#[test]
fn default_src_fallback_blocks_inline_script() {
let findings = csp_findings(
"<!doctype html><title>t</title>\
<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'\">\
<script>alert(1)</script>",
);
assert_eq!(findings.len(), 1);
}
#[test]
fn script_src_present_shadows_default_src_fallback() {
assert!(
csp_findings(
"<!doctype html><title>t</title>\
<meta http-equiv=\"Content-Security-Policy\" \
content=\"default-src 'self'; script-src 'unsafe-inline'\">\
<script>alert(1)</script>"
)
.is_empty()
);
}
#[test]
fn external_script_with_src_is_not_flagged() {
assert!(
csp_findings(
"<!doctype html><title>t</title>\
<meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'self'\">\
<script src=\"a.js\"></script>"
)
.is_empty()
);
}
}