diff --git a/Cargo.lock b/Cargo.lock index 5d12e5e..b943640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "we-browser" version = "0.1.0" dependencies = [ + "we-crypto", "we-css", "we-dom", "we-encoding", diff --git a/crates/browser/Cargo.toml b/crates/browser/Cargo.toml index eb27373..a7d895a 100644 --- a/crates/browser/Cargo.toml +++ b/crates/browser/Cargo.toml @@ -16,6 +16,7 @@ we-platform = { path = "../platform" } we-net = { path = "../net" } we-html = { path = "../html" } we-css = { path = "../css" } +we-crypto = { path = "../crypto" } we-dom = { path = "../dom" } we-style = { path = "../style" } we-layout = { path = "../layout" } diff --git a/crates/browser/src/csp.rs b/crates/browser/src/csp.rs new file mode 100644 index 0000000..be386bf --- /dev/null +++ b/crates/browser/src/csp.rs @@ -0,0 +1,1425 @@ +//! Content Security Policy (CSP) Level 2 implementation. +//! +//! Parses CSP from HTTP headers and `` tags, evaluates source expressions +//! against resource URLs, and enforces or reports violations. + +use we_crypto::sha2; +use we_url::data_url::base64_decode; +use we_url::{Origin, Url}; + +// --------------------------------------------------------------------------- +// Source expressions +// --------------------------------------------------------------------------- + +/// A single source expression within a directive value. +#[derive(Debug, Clone, PartialEq)] +pub enum SourceExpression { + /// `'none'` — nothing is allowed. + None, + /// `'self'` — same origin as the document. + Self_, + /// `'unsafe-inline'` — allow inline scripts/styles. + UnsafeInline, + /// `'unsafe-eval'` — allow `eval()` and similar. + UnsafeEval, + /// `'nonce-'` — nonce-based allowlist. + Nonce(String), + /// `'sha256-'`, `'sha384-'`, `'sha512-'`. + Hash(HashAlgorithm, Vec), + /// A scheme source: `https:`, `data:`, `blob:`, etc. + Scheme(String), + /// A host source: `example.com`, `*.example.com`, `https://example.com:443`. + Host(HostSource), +} + +/// Hash algorithm for CSP hash sources. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashAlgorithm { + Sha256, + Sha384, + Sha512, +} + +/// A parsed host source expression. +#[derive(Debug, Clone, PartialEq)] +pub struct HostSource { + /// Optional scheme restriction (e.g., `https`). + pub scheme: Option, + /// Host pattern. May start with `*.` for wildcard subdomains. + pub host: String, + /// Optional port restriction. + pub port: Option, +} + +// --------------------------------------------------------------------------- +// Directive types +// --------------------------------------------------------------------------- + +/// CSP fetch directive types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DirectiveType { + DefaultSrc, + ScriptSrc, + StyleSrc, + ImgSrc, + FontSrc, + ConnectSrc, + FrameSrc, + MediaSrc, + ObjectSrc, + BaseUri, + FormAction, +} + +impl DirectiveType { + fn parse(name: &str) -> Option { + match name { + "default-src" => Some(Self::DefaultSrc), + "script-src" => Some(Self::ScriptSrc), + "style-src" => Some(Self::StyleSrc), + "img-src" => Some(Self::ImgSrc), + "font-src" => Some(Self::FontSrc), + "connect-src" => Some(Self::ConnectSrc), + "frame-src" => Some(Self::FrameSrc), + "media-src" => Some(Self::MediaSrc), + "object-src" => Some(Self::ObjectSrc), + "base-uri" => Some(Self::BaseUri), + "form-action" => Some(Self::FormAction), + _ => Option::None, + } + } + + /// Returns true if this directive is a fetch directive that falls back to default-src. + fn has_default_fallback(self) -> bool { + matches!( + self, + Self::ScriptSrc + | Self::StyleSrc + | Self::ImgSrc + | Self::FontSrc + | Self::ConnectSrc + | Self::FrameSrc + | Self::MediaSrc + | Self::ObjectSrc + ) + } +} + +// --------------------------------------------------------------------------- +// Directive +// --------------------------------------------------------------------------- + +/// A single directive: a directive type with its source list. +#[derive(Debug, Clone)] +pub struct Directive { + pub directive_type: DirectiveType, + pub sources: Vec, +} + +// --------------------------------------------------------------------------- +// Policy +// --------------------------------------------------------------------------- + +/// A single CSP policy (from one header value or one meta tag). +#[derive(Debug, Clone)] +pub struct Policy { + pub directives: Vec, + /// If true, violations are reported but not enforced. + pub report_only: bool, +} + +impl Policy { + /// Look up the source list for a directive, falling back to default-src + /// for fetch directives. + fn source_list(&self, directive_type: DirectiveType) -> Option<&[SourceExpression]> { + // First try the specific directive. + for d in &self.directives { + if d.directive_type == directive_type { + return Some(&d.sources); + } + } + // Fall back to default-src for fetch directives. + if directive_type.has_default_fallback() { + for d in &self.directives { + if d.directive_type == DirectiveType::DefaultSrc { + return Some(&d.sources); + } + } + } + Option::None + } +} + +// --------------------------------------------------------------------------- +// PolicyList — multiple policies with intersection semantics +// --------------------------------------------------------------------------- + +/// A set of CSP policies. All enforced policies must allow a resource +/// (intersection semantics). +#[derive(Debug, Clone, Default)] +pub struct PolicyList { + pub policies: Vec, +} + +impl PolicyList { + pub fn new() -> Self { + Self { + policies: Vec::new(), + } + } + + pub fn is_empty(&self) -> bool { + self.policies.is_empty() + } + + /// Add a policy to the list. + pub fn add(&mut self, policy: Policy) { + self.policies.push(policy); + } + + /// Check if a URL is allowed by the given directive type across all policies. + /// Returns true if allowed (or if no policies define this directive). + pub fn allows_url( + &self, + url: &Url, + directive_type: DirectiveType, + document_origin: &Origin, + ) -> bool { + if self.policies.is_empty() { + return true; + } + + for policy in &self.policies { + if policy.report_only { + // Report-only: log but don't block. + if let Some(sources) = policy.source_list(directive_type) { + if !url_matches_source_list(url, sources, document_origin) { + log_violation(directive_type, &url.serialize(), policy.report_only); + } + } + continue; + } + + if let Some(sources) = policy.source_list(directive_type) { + if !url_matches_source_list(url, sources, document_origin) { + log_violation(directive_type, &url.serialize(), policy.report_only); + return false; + } + } + // If no directive found (and no default-src), the policy doesn't + // restrict this resource type — allow. + } + + true + } + + /// Check if inline content (script or style) is allowed. + /// + /// `content` is the raw inline text. `nonce` is the element's `nonce` + /// attribute, if any. + pub fn allows_inline( + &self, + directive_type: DirectiveType, + content: &str, + nonce: Option<&str>, + ) -> bool { + if self.policies.is_empty() { + return true; + } + + for policy in &self.policies { + if policy.report_only { + if let Some(sources) = policy.source_list(directive_type) { + if !inline_matches_source_list(sources, content, nonce) { + log_violation(directive_type, "", policy.report_only); + } + } + continue; + } + + if let Some(sources) = policy.source_list(directive_type) { + if !inline_matches_source_list(sources, content, nonce) { + log_violation(directive_type, "", policy.report_only); + return false; + } + } + } + + true + } + + /// Check if `eval()` is allowed by script-src. + pub fn allows_eval(&self) -> bool { + if self.policies.is_empty() { + return true; + } + + for policy in &self.policies { + if policy.report_only { + if let Some(sources) = policy.source_list(DirectiveType::ScriptSrc) { + if !sources.contains(&SourceExpression::UnsafeEval) { + log_violation(DirectiveType::ScriptSrc, "eval()", policy.report_only); + } + } + continue; + } + + if let Some(sources) = policy.source_list(DirectiveType::ScriptSrc) { + if !sources.contains(&SourceExpression::UnsafeEval) { + log_violation(DirectiveType::ScriptSrc, "eval()", policy.report_only); + return false; + } + } + } + + true + } +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parse a CSP header value into a Policy. +/// +/// The header value is a semicolon-separated list of directives. +/// Each directive is a directive name followed by space-separated source expressions. +pub fn parse_policy(header_value: &str, report_only: bool) -> Policy { + let mut directives = Vec::new(); + let mut seen_types = Vec::new(); + + for part in header_value.split(';') { + let part = part.trim(); + if part.is_empty() { + continue; + } + + let mut tokens = part.split_whitespace(); + let name = match tokens.next() { + Some(n) => n.to_ascii_lowercase(), + Option::None => continue, + }; + + let directive_type = match DirectiveType::parse(&name) { + Some(dt) => dt, + Option::None => continue, // Unknown directives are ignored. + }; + + // Per spec, duplicate directives are ignored (first wins). + if seen_types.contains(&directive_type) { + continue; + } + seen_types.push(directive_type); + + let sources: Vec = tokens.filter_map(parse_source_expression).collect(); + + directives.push(Directive { + directive_type, + sources, + }); + } + + Policy { + directives, + report_only, + } +} + +/// Parse CSP policies from HTTP response headers. +/// +/// Handles both `Content-Security-Policy` and `Content-Security-Policy-Report-Only`. +/// Multiple header values result in multiple policies (intersection semantics). +pub fn parse_from_headers(headers: &[(String, String)]) -> PolicyList { + let mut list = PolicyList::new(); + + for (name, value) in headers { + let name_lower = name.to_ascii_lowercase(); + if name_lower == "content-security-policy" { + list.add(parse_policy(value, false)); + } else if name_lower == "content-security-policy-report-only" { + list.add(parse_policy(value, true)); + } + } + + list +} + +/// Parse a single source expression token. +fn parse_source_expression(token: &str) -> Option { + let lower = token.to_ascii_lowercase(); + + // Keyword sources (quoted). + if lower == "'none'" { + return Some(SourceExpression::None); + } + if lower == "'self'" { + return Some(SourceExpression::Self_); + } + if lower == "'unsafe-inline'" { + return Some(SourceExpression::UnsafeInline); + } + if lower == "'unsafe-eval'" { + return Some(SourceExpression::UnsafeEval); + } + + // Nonce source: 'nonce-' + if let Some(rest) = strip_keyword_prefix(&lower, "'nonce-") { + if let Some(nonce_value) = rest.strip_suffix('\'') { + if !nonce_value.is_empty() { + // Extract from original token to preserve case of the nonce value. + let orig_nonce = &token[7..token.len() - 1]; + return Some(SourceExpression::Nonce(orig_nonce.to_string())); + } + } + return Option::None; + } + + // Hash source: 'sha256-', 'sha384-', 'sha512-' + for (prefix, algo) in &[ + ("'sha256-", HashAlgorithm::Sha256), + ("'sha384-", HashAlgorithm::Sha384), + ("'sha512-", HashAlgorithm::Sha512), + ] { + if let Some(rest) = strip_keyword_prefix(&lower, prefix) { + if rest.strip_suffix('\'').is_some() { + // Use original case for the base64 value. + let orig_b64 = &token[prefix.len()..token.len() - 1]; + if let Ok(hash_bytes) = base64_decode(orig_b64) { + return Some(SourceExpression::Hash(*algo, hash_bytes)); + } + } + return Option::None; + } + } + + // Scheme source: e.g., `https:`, `data:`, `blob:` + if lower.ends_with(':') && !lower.contains('/') { + let scheme = lower[..lower.len() - 1].to_string(); + if !scheme.is_empty() + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') + { + return Some(SourceExpression::Scheme(scheme)); + } + } + + // Host source: [scheme://]host[:port] + Some(parse_host_source(token)) +} + +/// Parse a host source expression. +fn parse_host_source(token: &str) -> SourceExpression { + let mut remaining = token; + let mut scheme = Option::None; + + // Extract optional scheme. + if let Some(idx) = remaining.find("://") { + scheme = Some(remaining[..idx].to_ascii_lowercase()); + remaining = &remaining[idx + 3..]; + } + + // Extract optional port. + let (host_part, port) = if let Some(colon_idx) = remaining.rfind(':') { + let port_str = &remaining[colon_idx + 1..]; + if let Ok(p) = port_str.parse::() { + (&remaining[..colon_idx], Some(p)) + } else { + (remaining, Option::None) + } + } else { + (remaining, Option::None) + }; + + let host = host_part.to_ascii_lowercase(); + + SourceExpression::Host(HostSource { scheme, host, port }) +} + +/// Helper: strip a prefix from a string, case-insensitively. +fn strip_keyword_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) { + Some(&s[prefix.len()..]) + } else { + Option::None + } +} + +// --------------------------------------------------------------------------- +// Source matching +// --------------------------------------------------------------------------- + +/// Check if a URL matches any source expression in a source list. +fn url_matches_source_list( + url: &Url, + sources: &[SourceExpression], + document_origin: &Origin, +) -> bool { + // 'none' means nothing is allowed. + if sources.len() == 1 && sources[0] == SourceExpression::None { + return false; + } + + for source in sources { + if url_matches_source(url, source, document_origin) { + return true; + } + } + + false +} + +/// Check if a URL matches a single source expression. +fn url_matches_source(url: &Url, source: &SourceExpression, document_origin: &Origin) -> bool { + match source { + SourceExpression::None => false, + SourceExpression::Self_ => { + let resource_origin = url.origin(); + document_origin.same_origin(&resource_origin) + } + SourceExpression::UnsafeInline | SourceExpression::UnsafeEval => false, + SourceExpression::Nonce(_) | SourceExpression::Hash(_, _) => false, + SourceExpression::Scheme(scheme) => url.scheme().eq_ignore_ascii_case(scheme), + SourceExpression::Host(host_src) => host_source_matches(url, host_src), + } +} + +/// Check if a URL matches a host source expression. +fn host_source_matches(url: &Url, host_src: &HostSource) -> bool { + // Check scheme if specified. + if let Some(ref scheme) = host_src.scheme { + if !url.scheme().eq_ignore_ascii_case(scheme) { + return false; + } + } + + // Check host. + let url_host = match url.host_str() { + Some(h) => h.to_ascii_lowercase(), + None => return false, + }; + let pattern = &host_src.host; + + if let Some(suffix) = pattern.strip_prefix("*.") { + // Wildcard: URL host must end with the suffix or equal it. + let suffix_lower = suffix.to_ascii_lowercase(); + if url_host == suffix_lower { + return check_port(url, host_src.port); + } + if url_host.ends_with(&format!(".{suffix_lower}")) { + return check_port(url, host_src.port); + } + return false; + } + + // Exact host match. + if !url_host.eq_ignore_ascii_case(pattern) { + return false; + } + + check_port(url, host_src.port) +} + +/// Check port restriction. +fn check_port(url: &Url, required_port: Option) -> bool { + match required_port { + Option::None => true, + Some(port) => { + let url_port = url.port().unwrap_or_else(|| default_port(url.scheme())); + url_port == port + } + } +} + +/// Return the default port for common schemes. +fn default_port(scheme: &str) -> u16 { + match scheme { + "http" => 80, + "https" => 443, + "ftp" => 21, + _ => 0, + } +} + +/// Check if inline content matches a source list. +/// +/// Inline content is allowed if: +/// - `'unsafe-inline'` is present (and no nonce/hash sources override it) +/// - A matching nonce is present +/// - A matching hash is present +fn inline_matches_source_list( + sources: &[SourceExpression], + content: &str, + nonce: Option<&str>, +) -> bool { + if sources.len() == 1 && sources[0] == SourceExpression::None { + return false; + } + + let has_nonce_or_hash = sources + .iter() + .any(|s| matches!(s, SourceExpression::Nonce(_) | SourceExpression::Hash(_, _))); + + // Check nonce match. + if let Some(nonce_value) = nonce { + for source in sources { + if let SourceExpression::Nonce(ref expected) = source { + if nonce_value == expected { + return true; + } + } + } + } + + // Check hash match. + for source in sources { + if let SourceExpression::Hash(algo, ref expected_hash) = source { + let computed = compute_hash(*algo, content.as_bytes()); + if computed == *expected_hash { + return true; + } + } + } + + // 'unsafe-inline' is only effective when no nonce or hash sources are present. + // Per CSP2: if a nonce or hash is specified, 'unsafe-inline' is ignored. + if !has_nonce_or_hash { + for source in sources { + if *source == SourceExpression::UnsafeInline { + return true; + } + } + } + + false +} + +/// Compute a hash of the given data with the specified algorithm. +fn compute_hash(algo: HashAlgorithm, data: &[u8]) -> Vec { + match algo { + HashAlgorithm::Sha256 => sha2::sha256(data).to_vec(), + HashAlgorithm::Sha384 => sha2::sha384(data).to_vec(), + HashAlgorithm::Sha512 => sha2::sha512(data).to_vec(), + } +} + +// --------------------------------------------------------------------------- +// Violation reporting +// --------------------------------------------------------------------------- + +fn log_violation(directive_type: DirectiveType, resource: &str, report_only: bool) { + let mode = if report_only { + "report-only" + } else { + "enforced" + }; + let directive_name = match directive_type { + DirectiveType::DefaultSrc => "default-src", + DirectiveType::ScriptSrc => "script-src", + DirectiveType::StyleSrc => "style-src", + DirectiveType::ImgSrc => "img-src", + DirectiveType::FontSrc => "font-src", + DirectiveType::ConnectSrc => "connect-src", + DirectiveType::FrameSrc => "frame-src", + DirectiveType::MediaSrc => "media-src", + DirectiveType::ObjectSrc => "object-src", + DirectiveType::BaseUri => "base-uri", + DirectiveType::FormAction => "form-action", + }; + eprintln!("[csp] {mode} violation: {directive_name} blocked {resource}"); +} + +// --------------------------------------------------------------------------- +// Meta tag extraction +// --------------------------------------------------------------------------- + +/// Extract CSP policies from `` tags. +/// +/// Note: per spec, `Content-Security-Policy-Report-Only` is NOT valid in meta tags. +pub fn extract_meta_csp(doc: &we_dom::Document) -> PolicyList { + use we_dom::NodeId; + + let mut list = PolicyList::new(); + for i in 0..doc.len() { + let node_id = NodeId::from_index(i); + if doc.tag_name(node_id) == Some("meta") { + if let Some(http_equiv) = doc.get_attribute(node_id, "http-equiv") { + if http_equiv.eq_ignore_ascii_case("Content-Security-Policy") { + if let Some(content) = doc.get_attribute(node_id, "content") { + list.add(parse_policy(content, false)); + } + } + } + } + } + list +} + +// --------------------------------------------------------------------------- +// Convenience: map ResourceRequestType to CSP directive +// --------------------------------------------------------------------------- + +/// Map a resource request type to the appropriate CSP directive. +pub fn directive_for_request_type( + request_type: crate::loader::ResourceRequestType, +) -> Option { + use crate::loader::ResourceRequestType; + match request_type { + ResourceRequestType::Script => Some(DirectiveType::ScriptSrc), + ResourceRequestType::Stylesheet => Some(DirectiveType::StyleSrc), + ResourceRequestType::Image => Some(DirectiveType::ImgSrc), + ResourceRequestType::Font => Some(DirectiveType::FontSrc), + ResourceRequestType::Fetch => Some(DirectiveType::ConnectSrc), + ResourceRequestType::Navigation => Option::None, // CSP doesn't restrict navigation via fetch directives + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // Parsing + // ----------------------------------------------------------------------- + + #[test] + fn parse_empty_policy() { + let policy = parse_policy("", false); + assert!(policy.directives.is_empty()); + assert!(!policy.report_only); + } + + #[test] + fn parse_single_directive() { + let policy = parse_policy("default-src 'self'", false); + assert_eq!(policy.directives.len(), 1); + assert_eq!( + policy.directives[0].directive_type, + DirectiveType::DefaultSrc + ); + assert_eq!(policy.directives[0].sources.len(), 1); + assert_eq!(policy.directives[0].sources[0], SourceExpression::Self_); + } + + #[test] + fn parse_multiple_directives() { + let policy = parse_policy( + "default-src 'none'; script-src 'self'; img-src https:", + false, + ); + assert_eq!(policy.directives.len(), 3); + } + + #[test] + fn parse_duplicate_directive_first_wins() { + let policy = parse_policy("script-src 'self'; script-src 'none'", false); + assert_eq!(policy.directives.len(), 1); + assert_eq!(policy.directives[0].sources[0], SourceExpression::Self_); + } + + #[test] + fn parse_unknown_directive_ignored() { + let policy = parse_policy("unknown-directive 'self'; script-src 'self'", false); + assert_eq!(policy.directives.len(), 1); + assert_eq!( + policy.directives[0].directive_type, + DirectiveType::ScriptSrc + ); + } + + #[test] + fn parse_report_only() { + let policy = parse_policy("default-src 'self'", true); + assert!(policy.report_only); + } + + // ----------------------------------------------------------------------- + // Source expression parsing + // ----------------------------------------------------------------------- + + #[test] + fn parse_none_source() { + let s = parse_source_expression("'none'").unwrap(); + assert_eq!(s, SourceExpression::None); + } + + #[test] + fn parse_self_source() { + let s = parse_source_expression("'self'").unwrap(); + assert_eq!(s, SourceExpression::Self_); + } + + #[test] + fn parse_unsafe_inline() { + let s = parse_source_expression("'unsafe-inline'").unwrap(); + assert_eq!(s, SourceExpression::UnsafeInline); + } + + #[test] + fn parse_unsafe_eval() { + let s = parse_source_expression("'unsafe-eval'").unwrap(); + assert_eq!(s, SourceExpression::UnsafeEval); + } + + #[test] + fn parse_nonce_source() { + let s = parse_source_expression("'nonce-abc123'").unwrap(); + assert_eq!(s, SourceExpression::Nonce("abc123".to_string())); + } + + #[test] + fn parse_hash_sha256() { + // SHA-256 hash of empty string in base64 + let s = parse_source_expression("'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='"); + assert!(s.is_some()); + if let Some(SourceExpression::Hash(algo, _)) = s { + assert_eq!(algo, HashAlgorithm::Sha256); + } else { + panic!("expected Hash source"); + } + } + + #[test] + fn parse_scheme_source() { + let s = parse_source_expression("https:").unwrap(); + assert_eq!(s, SourceExpression::Scheme("https".to_string())); + } + + #[test] + fn parse_data_scheme_source() { + let s = parse_source_expression("data:").unwrap(); + assert_eq!(s, SourceExpression::Scheme("data".to_string())); + } + + #[test] + fn parse_host_source_simple() { + let s = parse_source_expression("example.com").unwrap(); + if let SourceExpression::Host(h) = s { + assert_eq!(h.host, "example.com"); + assert!(h.scheme.is_none()); + assert!(h.port.is_none()); + } else { + panic!("expected Host source"); + } + } + + #[test] + fn parse_host_source_with_scheme() { + let s = parse_source_expression("https://example.com").unwrap(); + if let SourceExpression::Host(h) = s { + assert_eq!(h.scheme.as_deref(), Some("https")); + assert_eq!(h.host, "example.com"); + } else { + panic!("expected Host source"); + } + } + + #[test] + fn parse_host_source_with_port() { + let s = parse_source_expression("example.com:8080").unwrap(); + if let SourceExpression::Host(h) = s { + assert_eq!(h.host, "example.com"); + assert_eq!(h.port, Some(8080)); + } else { + panic!("expected Host source"); + } + } + + #[test] + fn parse_host_source_wildcard() { + let s = parse_source_expression("*.example.com").unwrap(); + if let SourceExpression::Host(h) = s { + assert_eq!(h.host, "*.example.com"); + } else { + panic!("expected Host source"); + } + } + + #[test] + fn parse_host_source_full() { + let s = parse_source_expression("https://example.com:443").unwrap(); + if let SourceExpression::Host(h) = s { + assert_eq!(h.scheme.as_deref(), Some("https")); + assert_eq!(h.host, "example.com"); + assert_eq!(h.port, Some(443)); + } else { + panic!("expected Host source"); + } + } + + // ----------------------------------------------------------------------- + // Keyword case insensitivity + // ----------------------------------------------------------------------- + + #[test] + fn parse_keywords_case_insensitive() { + assert_eq!( + parse_source_expression("'SELF'").unwrap(), + SourceExpression::Self_ + ); + assert_eq!( + parse_source_expression("'Unsafe-Inline'").unwrap(), + SourceExpression::UnsafeInline + ); + assert_eq!( + parse_source_expression("'UNSAFE-EVAL'").unwrap(), + SourceExpression::UnsafeEval + ); + } + + // ----------------------------------------------------------------------- + // URL matching + // ----------------------------------------------------------------------- + + fn test_origin() -> Origin { + Url::parse("https://example.com/page").unwrap().origin() + } + + #[test] + fn self_matches_same_origin() { + let url = Url::parse("https://example.com/script.js").unwrap(); + let origin = test_origin(); + assert!(url_matches_source(&url, &SourceExpression::Self_, &origin)); + } + + #[test] + fn self_does_not_match_different_origin() { + let url = Url::parse("https://other.com/script.js").unwrap(); + let origin = test_origin(); + assert!(!url_matches_source(&url, &SourceExpression::Self_, &origin)); + } + + #[test] + fn scheme_matches() { + let url = Url::parse("https://any.com/path").unwrap(); + let origin = test_origin(); + assert!(url_matches_source( + &url, + &SourceExpression::Scheme("https".to_string()), + &origin, + )); + } + + #[test] + fn scheme_does_not_match_different() { + let url = Url::parse("http://any.com/path").unwrap(); + let origin = test_origin(); + assert!(!url_matches_source( + &url, + &SourceExpression::Scheme("https".to_string()), + &origin, + )); + } + + #[test] + fn host_exact_match() { + let url = Url::parse("https://cdn.example.com/lib.js").unwrap(); + let origin = test_origin(); + let source = SourceExpression::Host(HostSource { + scheme: None, + host: "cdn.example.com".to_string(), + port: None, + }); + assert!(url_matches_source(&url, &source, &origin)); + } + + #[test] + fn host_wildcard_match() { + let url = Url::parse("https://cdn.example.com/lib.js").unwrap(); + let origin = test_origin(); + let source = SourceExpression::Host(HostSource { + scheme: None, + host: "*.example.com".to_string(), + port: None, + }); + assert!(url_matches_source(&url, &source, &origin)); + } + + #[test] + fn host_wildcard_does_not_match_base_domain() { + // *.example.com should match sub.example.com but also example.com per CSP spec + let url = Url::parse("https://example.com/lib.js").unwrap(); + let origin = test_origin(); + let source = SourceExpression::Host(HostSource { + scheme: None, + host: "*.example.com".to_string(), + port: None, + }); + assert!(url_matches_source(&url, &source, &origin)); + } + + #[test] + fn host_with_scheme_restriction() { + let url = Url::parse("http://example.com/script.js").unwrap(); + let origin = test_origin(); + let source = SourceExpression::Host(HostSource { + scheme: Some("https".to_string()), + host: "example.com".to_string(), + port: None, + }); + assert!(!url_matches_source(&url, &source, &origin)); + } + + #[test] + fn host_with_port_restriction() { + let url = Url::parse("https://example.com:8080/script.js").unwrap(); + let origin = test_origin(); + let source = SourceExpression::Host(HostSource { + scheme: None, + host: "example.com".to_string(), + port: Some(443), + }); + assert!(!url_matches_source(&url, &source, &origin)); + } + + #[test] + fn none_blocks_everything() { + let url = Url::parse("https://example.com/script.js").unwrap(); + let origin = test_origin(); + assert!(!url_matches_source_list( + &url, + &[SourceExpression::None], + &origin, + )); + } + + // ----------------------------------------------------------------------- + // Inline matching + // ----------------------------------------------------------------------- + + #[test] + fn inline_blocked_by_default() { + let sources = vec![SourceExpression::Self_]; + assert!(!inline_matches_source_list(&sources, "alert(1)", None)); + } + + #[test] + fn inline_allowed_by_unsafe_inline() { + let sources = vec![SourceExpression::Self_, SourceExpression::UnsafeInline]; + assert!(inline_matches_source_list(&sources, "alert(1)", None)); + } + + #[test] + fn inline_allowed_by_nonce() { + let sources = vec![ + SourceExpression::Self_, + SourceExpression::Nonce("abc123".to_string()), + ]; + assert!(inline_matches_source_list( + &sources, + "alert(1)", + Some("abc123"), + )); + } + + #[test] + fn inline_blocked_by_wrong_nonce() { + let sources = vec![ + SourceExpression::Self_, + SourceExpression::Nonce("abc123".to_string()), + ]; + assert!(!inline_matches_source_list( + &sources, + "alert(1)", + Some("wrong"), + )); + } + + #[test] + fn inline_allowed_by_hash() { + let content = "alert(1)"; + let hash = sha2::sha256(content.as_bytes()).to_vec(); + let sources = vec![ + SourceExpression::Self_, + SourceExpression::Hash(HashAlgorithm::Sha256, hash), + ]; + assert!(inline_matches_source_list(&sources, content, None)); + } + + #[test] + fn inline_blocked_by_wrong_hash() { + let content = "alert(1)"; + let sources = vec![ + SourceExpression::Self_, + SourceExpression::Hash(HashAlgorithm::Sha256, vec![0; 32]), + ]; + assert!(!inline_matches_source_list(&sources, content, None)); + } + + #[test] + fn unsafe_inline_ignored_when_nonce_present() { + // Per CSP2: 'unsafe-inline' is ignored if a nonce or hash is present. + let sources = vec![ + SourceExpression::UnsafeInline, + SourceExpression::Nonce("expected".to_string()), + ]; + // No nonce attribute, no matching nonce → blocked despite 'unsafe-inline'. + assert!(!inline_matches_source_list(&sources, "alert(1)", None)); + } + + #[test] + fn unsafe_inline_ignored_when_hash_present() { + let sources = vec![ + SourceExpression::UnsafeInline, + SourceExpression::Hash(HashAlgorithm::Sha256, vec![0; 32]), + ]; + // Hash doesn't match → blocked despite 'unsafe-inline'. + assert!(!inline_matches_source_list(&sources, "alert(1)", None)); + } + + // ----------------------------------------------------------------------- + // Eval checking + // ----------------------------------------------------------------------- + + #[test] + fn eval_blocked_by_default() { + let mut list = PolicyList::new(); + list.add(parse_policy("script-src 'self'", false)); + assert!(!list.allows_eval()); + } + + #[test] + fn eval_allowed_by_unsafe_eval() { + let mut list = PolicyList::new(); + list.add(parse_policy("script-src 'self' 'unsafe-eval'", false)); + assert!(list.allows_eval()); + } + + #[test] + fn eval_allowed_when_no_policy() { + let list = PolicyList::new(); + assert!(list.allows_eval()); + } + + #[test] + fn eval_falls_back_to_default_src() { + let mut list = PolicyList::new(); + list.add(parse_policy("default-src 'self' 'unsafe-eval'", false)); + assert!(list.allows_eval()); + } + + #[test] + fn eval_blocked_by_default_src_fallback() { + let mut list = PolicyList::new(); + list.add(parse_policy("default-src 'self'", false)); + assert!(!list.allows_eval()); + } + + // ----------------------------------------------------------------------- + // PolicyList intersection semantics + // ----------------------------------------------------------------------- + + #[test] + fn multiple_policies_intersection() { + let mut list = PolicyList::new(); + // Policy 1: allow scripts from self and cdn.example.com + list.add(parse_policy("script-src 'self' cdn.example.com", false)); + // Policy 2: allow scripts from self only + list.add(parse_policy("script-src 'self'", false)); + + let origin = test_origin(); + let self_url = Url::parse("https://example.com/script.js").unwrap(); + let cdn_url = Url::parse("https://cdn.example.com/lib.js").unwrap(); + + // Self is allowed by both policies. + assert!(list.allows_url(&self_url, DirectiveType::ScriptSrc, &origin)); + // CDN is allowed by policy 1 but blocked by policy 2. + assert!(!list.allows_url(&cdn_url, DirectiveType::ScriptSrc, &origin)); + } + + #[test] + fn report_only_does_not_block() { + let mut list = PolicyList::new(); + list.add(parse_policy("script-src 'none'", true)); + + let origin = test_origin(); + let url = Url::parse("https://example.com/script.js").unwrap(); + + // Report-only should not block. + assert!(list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); + } + + #[test] + fn enforced_and_report_only_together() { + let mut list = PolicyList::new(); + // Enforced policy: allow self + list.add(parse_policy("script-src 'self'", false)); + // Report-only: block everything (should not affect enforcement) + list.add(parse_policy("script-src 'none'", true)); + + let origin = test_origin(); + let url = Url::parse("https://example.com/script.js").unwrap(); + + // Should be allowed (enforced policy allows 'self', report-only doesn't block). + assert!(list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); + } + + // ----------------------------------------------------------------------- + // Default-src fallback + // ----------------------------------------------------------------------- + + #[test] + fn default_src_fallback_for_script() { + let mut list = PolicyList::new(); + list.add(parse_policy("default-src 'self'", false)); + + let origin = test_origin(); + let url = Url::parse("https://example.com/script.js").unwrap(); + assert!(list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); + } + + #[test] + fn specific_directive_overrides_default() { + let mut list = PolicyList::new(); + list.add(parse_policy("default-src 'self'; script-src 'none'", false)); + + let origin = test_origin(); + let url = Url::parse("https://example.com/script.js").unwrap(); + // script-src 'none' overrides default-src 'self'. + assert!(!list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); + } + + #[test] + fn no_directive_and_no_default_allows() { + let mut list = PolicyList::new(); + // Only img-src specified, no default-src. + list.add(parse_policy("img-src 'self'", false)); + + let origin = test_origin(); + let url = Url::parse("https://evil.com/script.js").unwrap(); + // No script-src and no default-src → not restricted by this policy. + assert!(list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); + } + + // ----------------------------------------------------------------------- + // Default-src does NOT apply to base-uri and form-action + // ----------------------------------------------------------------------- + + #[test] + fn default_src_does_not_fallback_for_base_uri() { + let mut list = PolicyList::new(); + list.add(parse_policy("default-src 'none'", false)); + + let origin = test_origin(); + let url = Url::parse("https://evil.com/").unwrap(); + // base-uri doesn't fall back to default-src. + assert!(list.allows_url(&url, DirectiveType::BaseUri, &origin)); + } + + #[test] + fn default_src_does_not_fallback_for_form_action() { + let mut list = PolicyList::new(); + list.add(parse_policy("default-src 'none'", false)); + + let origin = test_origin(); + let url = Url::parse("https://evil.com/submit").unwrap(); + assert!(list.allows_url(&url, DirectiveType::FormAction, &origin)); + } + + // ----------------------------------------------------------------------- + // Parse from headers + // ----------------------------------------------------------------------- + + #[test] + fn parse_from_headers_enforced() { + let headers = vec![( + "Content-Security-Policy".to_string(), + "default-src 'self'".to_string(), + )]; + let list = parse_from_headers(&headers); + assert_eq!(list.policies.len(), 1); + assert!(!list.policies[0].report_only); + } + + #[test] + fn parse_from_headers_report_only() { + let headers = vec![( + "Content-Security-Policy-Report-Only".to_string(), + "default-src 'self'".to_string(), + )]; + let list = parse_from_headers(&headers); + assert_eq!(list.policies.len(), 1); + assert!(list.policies[0].report_only); + } + + #[test] + fn parse_from_headers_multiple() { + let headers = vec![ + ( + "Content-Security-Policy".to_string(), + "script-src 'self'".to_string(), + ), + ( + "Content-Security-Policy".to_string(), + "script-src https:".to_string(), + ), + ]; + let list = parse_from_headers(&headers); + assert_eq!(list.policies.len(), 2); + } + + #[test] + fn parse_from_headers_case_insensitive() { + let headers = vec![( + "content-security-policy".to_string(), + "default-src 'self'".to_string(), + )]; + let list = parse_from_headers(&headers); + assert_eq!(list.policies.len(), 1); + } + + // ----------------------------------------------------------------------- + // All directive types parse correctly + // ----------------------------------------------------------------------- + + #[test] + fn all_directive_types() { + let policy = parse_policy( + "default-src 'self'; script-src 'self'; style-src 'self'; \ + img-src 'self'; font-src 'self'; connect-src 'self'; \ + frame-src 'self'; media-src 'self'; object-src 'self'; \ + base-uri 'self'; form-action 'self'", + false, + ); + assert_eq!(policy.directives.len(), 11); + } + + // ----------------------------------------------------------------------- + // allows_inline integration + // ----------------------------------------------------------------------- + + #[test] + fn allows_inline_no_policy() { + let list = PolicyList::new(); + assert!(list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", None)); + } + + #[test] + fn allows_inline_blocked_by_self_only() { + let mut list = PolicyList::new(); + list.add(parse_policy("script-src 'self'", false)); + assert!(!list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", None)); + } + + #[test] + fn allows_inline_with_unsafe_inline() { + let mut list = PolicyList::new(); + list.add(parse_policy("script-src 'self' 'unsafe-inline'", false)); + assert!(list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", None)); + } + + #[test] + fn allows_inline_with_nonce_match() { + let mut list = PolicyList::new(); + list.add(parse_policy("script-src 'nonce-abc123'", false)); + assert!(list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", Some("abc123"),)); + } + + #[test] + fn allows_inline_with_nonce_mismatch() { + let mut list = PolicyList::new(); + list.add(parse_policy("script-src 'nonce-abc123'", false)); + assert!(!list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", Some("wrong"),)); + } + + // ----------------------------------------------------------------------- + // Hash integration test + // ----------------------------------------------------------------------- + + #[test] + fn hash_source_matches_content() { + // Compute the SHA-256 of "alert(1)" and encode in base64. + let hash = sha2::sha256(b"alert(1)"); + let b64 = base64_encode(&hash); + let policy_str = format!("script-src 'sha256-{b64}'"); + + let mut list = PolicyList::new(); + list.add(parse_policy(&policy_str, false)); + assert!(list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", None)); + } + + #[test] + fn hash_source_does_not_match_different_content() { + let hash = sha2::sha256(b"alert(1)"); + let b64 = base64_encode(&hash); + let policy_str = format!("script-src 'sha256-{b64}'"); + + let mut list = PolicyList::new(); + list.add(parse_policy(&policy_str, false)); + assert!(!list.allows_inline(DirectiveType::ScriptSrc, "alert(2)", None,)); + } + + /// Simple base64 encoder for tests only. + fn base64_encode(data: &[u8]) -> String { + const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::new(); + let mut i = 0; + while i < data.len() { + let b0 = data[i] as u32; + let b1 = if i + 1 < data.len() { + data[i + 1] as u32 + } else { + 0 + }; + let b2 = if i + 2 < data.len() { + data[i + 2] as u32 + } else { + 0 + }; + let triple = (b0 << 16) | (b1 << 8) | b2; + + result.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char); + result.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char); + if i + 1 < data.len() { + result.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char); + } else { + result.push('='); + } + if i + 2 < data.len() { + result.push(ALPHABET[(triple & 0x3F) as usize] as char); + } else { + result.push('='); + } + i += 3; + } + result + } + + // ----------------------------------------------------------------------- + // directive_for_request_type + // ----------------------------------------------------------------------- + + #[test] + fn request_type_to_directive() { + use crate::loader::ResourceRequestType; + assert_eq!( + directive_for_request_type(ResourceRequestType::Script), + Some(DirectiveType::ScriptSrc), + ); + assert_eq!( + directive_for_request_type(ResourceRequestType::Stylesheet), + Some(DirectiveType::StyleSrc), + ); + assert_eq!( + directive_for_request_type(ResourceRequestType::Image), + Some(DirectiveType::ImgSrc), + ); + assert_eq!( + directive_for_request_type(ResourceRequestType::Font), + Some(DirectiveType::FontSrc), + ); + assert_eq!( + directive_for_request_type(ResourceRequestType::Fetch), + Some(DirectiveType::ConnectSrc), + ); + assert_eq!( + directive_for_request_type(ResourceRequestType::Navigation), + None, + ); + } +} diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs index 656817a..6a1f6e8 100644 --- a/crates/browser/src/lib.rs +++ b/crates/browser/src/lib.rs @@ -1,5 +1,6 @@ //! Event loop, resource loading, navigation, UI chrome. +pub mod csp; pub mod css_loader; pub mod font_loader; pub mod img_loader; diff --git a/crates/browser/src/loader.rs b/crates/browser/src/loader.rs index 9db666e..e79bf4b 100644 --- a/crates/browser/src/loader.rs +++ b/crates/browser/src/loader.rs @@ -6,6 +6,7 @@ use std::fmt; +use crate::csp::{self, PolicyList}; use we_encoding::sniff::sniff_encoding; use we_encoding::Encoding; use we_net::client::{ClientError, HttpClient}; @@ -35,6 +36,8 @@ pub enum LoadError { Encoding(String), /// Blocked by Same-Origin Policy. CrossOriginBlocked { url: String }, + /// Blocked by Content Security Policy. + CspBlocked { url: String, directive: String }, } impl fmt::Display for LoadError { @@ -49,6 +52,9 @@ impl fmt::Display for LoadError { Self::CrossOriginBlocked { url } => { write!(f, "cross-origin request blocked: {url}") } + Self::CspBlocked { url, directive } => { + write!(f, "blocked by CSP {directive}: {url}") + } } } } @@ -119,6 +125,8 @@ pub struct ResourceLoader { document_url: Option, /// The active referrer policy for this document. referrer_policy: ReferrerPolicy, + /// Active Content Security Policies for this document. + csp: PolicyList, } impl ResourceLoader { @@ -129,6 +137,7 @@ impl ResourceLoader { preflight_cache: PreflightCache::new(), document_url: None, referrer_policy: ReferrerPolicy::default(), + csp: PolicyList::new(), } } @@ -181,6 +190,56 @@ impl ResourceLoader { } } + /// Set the Content Security Policy for this document. + pub fn set_csp(&mut self, csp: PolicyList) { + self.csp = csp; + } + + /// Get a reference to the active CSP policies. + pub fn csp(&self) -> &PolicyList { + &self.csp + } + + /// Parse CSP from HTTP response headers and add to the policy list. + pub fn update_csp_from_response(&mut self, response: &HttpResponse) { + let headers: Vec<(String, String)> = response + .headers + .iter() + .map(|(n, v)| (n.to_string(), v.to_string())) + .collect(); + let new_policies = csp::parse_from_headers(&headers); + for policy in new_policies.policies { + self.csp.add(policy); + } + } + + /// Check if a URL is allowed by CSP for the given request type. + /// Returns Ok(()) if allowed, Err(LoadError::CspBlocked) if blocked. + fn check_csp(&self, url: &Url, request_type: ResourceRequestType) -> Result<(), LoadError> { + if self.csp.is_empty() { + return Ok(()); + } + + let directive = match csp::directive_for_request_type(request_type) { + Some(d) => d, + None => return Ok(()), // Navigation not restricted by fetch directives. + }; + + let document_origin = match &self.document_url { + Some(doc_url) => doc_url.origin(), + None => return Ok(()), // No document URL means no CSP enforcement. + }; + + if self.csp.allows_url(url, directive, &document_origin) { + Ok(()) + } else { + Err(LoadError::CspBlocked { + url: url.serialize(), + directive: format!("{directive:?}"), + }) + } + } + /// Fetch a resource at the given URL. /// /// Determines the resource type from the HTTP Content-Type header, decodes @@ -203,6 +262,7 @@ impl ResourceLoader { self.add_referer_header(&mut headers, url, None); let response = self.client.request(Method::Get, url, &headers, None)?; self.update_policy_from_response(&response); + self.update_csp_from_response(&response); // Check for HTTP error status codes if response.status_code >= 400 { @@ -344,6 +404,9 @@ impl ResourceLoader { credentials_mode: CredentialsMode, element_policy: Option, ) -> Result { + // Check CSP before any fetch. + self.check_csp(url, request_type)?; + // data: and about: URLs are always allowed (local, no network). if url.scheme() == "data" || url.scheme() == "about" { return self.fetch(url); diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index b8b70e5..ee142c0 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::collections::HashMap; +use we_browser::csp::{self, PolicyList}; use we_browser::css_loader::collect_stylesheets; use we_browser::font_loader::load_web_fonts; use we_browser::img_loader::{collect_images, ImageStore}; @@ -344,6 +345,8 @@ struct LoadedHtml { base_url: Url, /// Referrer policy from the HTTP Referrer-Policy response header, if any. http_referrer_policy: Option, + /// CSP policies from HTTP response headers. + http_csp: PolicyList, } /// Load content from a command-line argument. @@ -367,10 +370,13 @@ fn load_from_arg(arg: &str) -> LoadedHtml { } else { None }; + // Capture CSP policies from the HTTP response. + let http_csp = loader.csp().clone(); return LoadedHtml { text, base_url, http_referrer_policy: http_policy, + http_csp, }; } Ok(_) => { @@ -396,6 +402,7 @@ fn load_from_arg(arg: &str) -> LoadedHtml { text: content, base_url, http_referrer_policy: None, + http_csp: PolicyList::new(), } } Err(e) => error_page(&format!("Error reading {arg}: {e}")), @@ -424,6 +431,7 @@ fn error_page(message: &str) -> LoadedHtml { text: html, base_url, http_referrer_policy: None, + http_csp: PolicyList::new(), } } @@ -455,10 +463,18 @@ fn load_page(loaded: LoadedHtml) -> PageState { // Determine the referrer policy. Priority: meta tag > HTTP header > default. let meta_policy = extract_meta_referrer_policy(&doc); + // Merge CSP policies: HTTP headers + meta tags. + let meta_csp = csp::extract_meta_csp(&doc); + let mut csp_policies = loaded.http_csp; + for policy in meta_csp.policies { + csp_policies.add(policy); + } + // Execute