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