we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement Content Security Policy (CSP) Level 2: parsing, enforcement, and integration

- Parse CSP from Content-Security-Policy and Content-Security-Policy-Report-Only HTTP headers
- Parse CSP from <meta http-equiv="Content-Security-Policy"> elements
- Support all fetch directives: default-src, script-src, style-src, img-src, font-src,
connect-src, frame-src, media-src, object-src, base-uri, form-action
- Support source expressions: 'none', 'self', 'unsafe-inline', 'unsafe-eval',
nonce sources, hash sources (sha256/sha384/sha512), scheme sources, host sources
- Enforce default-src fallback for fetch directives
- Block inline scripts unless allowed by 'unsafe-inline', matching nonce, or matching hash
- Nonce/hash presence disables 'unsafe-inline' per CSP2 spec
- Multiple policies use intersection semantics (all must allow)
- Report-only policies log violations but do not block
- Integrate CSP checks into ResourceLoader.fetch_subresource
- Integrate inline script CSP checks into script_loader with nonce attribute support
- 64 unit tests covering parsing, matching, enforcement, and edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1521 -1
+1
Cargo.lock
··· 6 6 name = "we-browser" 7 7 version = "0.1.0" 8 8 dependencies = [ 9 + "we-crypto", 9 10 "we-css", 10 11 "we-dom", 11 12 "we-encoding",
+1
crates/browser/Cargo.toml
··· 16 16 we-net = { path = "../net" } 17 17 we-html = { path = "../html" } 18 18 we-css = { path = "../css" } 19 + we-crypto = { path = "../crypto" } 19 20 we-dom = { path = "../dom" } 20 21 we-style = { path = "../style" } 21 22 we-layout = { path = "../layout" }
+1425
crates/browser/src/csp.rs
··· 1 + //! Content Security Policy (CSP) Level 2 implementation. 2 + //! 3 + //! Parses CSP from HTTP headers and `<meta>` tags, evaluates source expressions 4 + //! against resource URLs, and enforces or reports violations. 5 + 6 + use we_crypto::sha2; 7 + use we_url::data_url::base64_decode; 8 + use we_url::{Origin, Url}; 9 + 10 + // --------------------------------------------------------------------------- 11 + // Source expressions 12 + // --------------------------------------------------------------------------- 13 + 14 + /// A single source expression within a directive value. 15 + #[derive(Debug, Clone, PartialEq)] 16 + pub enum SourceExpression { 17 + /// `'none'` — nothing is allowed. 18 + None, 19 + /// `'self'` — same origin as the document. 20 + Self_, 21 + /// `'unsafe-inline'` — allow inline scripts/styles. 22 + UnsafeInline, 23 + /// `'unsafe-eval'` — allow `eval()` and similar. 24 + UnsafeEval, 25 + /// `'nonce-<base64>'` — nonce-based allowlist. 26 + Nonce(String), 27 + /// `'sha256-<base64>'`, `'sha384-<base64>'`, `'sha512-<base64>'`. 28 + Hash(HashAlgorithm, Vec<u8>), 29 + /// A scheme source: `https:`, `data:`, `blob:`, etc. 30 + Scheme(String), 31 + /// A host source: `example.com`, `*.example.com`, `https://example.com:443`. 32 + Host(HostSource), 33 + } 34 + 35 + /// Hash algorithm for CSP hash sources. 36 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 37 + pub enum HashAlgorithm { 38 + Sha256, 39 + Sha384, 40 + Sha512, 41 + } 42 + 43 + /// A parsed host source expression. 44 + #[derive(Debug, Clone, PartialEq)] 45 + pub struct HostSource { 46 + /// Optional scheme restriction (e.g., `https`). 47 + pub scheme: Option<String>, 48 + /// Host pattern. May start with `*.` for wildcard subdomains. 49 + pub host: String, 50 + /// Optional port restriction. 51 + pub port: Option<u16>, 52 + } 53 + 54 + // --------------------------------------------------------------------------- 55 + // Directive types 56 + // --------------------------------------------------------------------------- 57 + 58 + /// CSP fetch directive types. 59 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 60 + pub enum DirectiveType { 61 + DefaultSrc, 62 + ScriptSrc, 63 + StyleSrc, 64 + ImgSrc, 65 + FontSrc, 66 + ConnectSrc, 67 + FrameSrc, 68 + MediaSrc, 69 + ObjectSrc, 70 + BaseUri, 71 + FormAction, 72 + } 73 + 74 + impl DirectiveType { 75 + fn parse(name: &str) -> Option<Self> { 76 + match name { 77 + "default-src" => Some(Self::DefaultSrc), 78 + "script-src" => Some(Self::ScriptSrc), 79 + "style-src" => Some(Self::StyleSrc), 80 + "img-src" => Some(Self::ImgSrc), 81 + "font-src" => Some(Self::FontSrc), 82 + "connect-src" => Some(Self::ConnectSrc), 83 + "frame-src" => Some(Self::FrameSrc), 84 + "media-src" => Some(Self::MediaSrc), 85 + "object-src" => Some(Self::ObjectSrc), 86 + "base-uri" => Some(Self::BaseUri), 87 + "form-action" => Some(Self::FormAction), 88 + _ => Option::None, 89 + } 90 + } 91 + 92 + /// Returns true if this directive is a fetch directive that falls back to default-src. 93 + fn has_default_fallback(self) -> bool { 94 + matches!( 95 + self, 96 + Self::ScriptSrc 97 + | Self::StyleSrc 98 + | Self::ImgSrc 99 + | Self::FontSrc 100 + | Self::ConnectSrc 101 + | Self::FrameSrc 102 + | Self::MediaSrc 103 + | Self::ObjectSrc 104 + ) 105 + } 106 + } 107 + 108 + // --------------------------------------------------------------------------- 109 + // Directive 110 + // --------------------------------------------------------------------------- 111 + 112 + /// A single directive: a directive type with its source list. 113 + #[derive(Debug, Clone)] 114 + pub struct Directive { 115 + pub directive_type: DirectiveType, 116 + pub sources: Vec<SourceExpression>, 117 + } 118 + 119 + // --------------------------------------------------------------------------- 120 + // Policy 121 + // --------------------------------------------------------------------------- 122 + 123 + /// A single CSP policy (from one header value or one meta tag). 124 + #[derive(Debug, Clone)] 125 + pub struct Policy { 126 + pub directives: Vec<Directive>, 127 + /// If true, violations are reported but not enforced. 128 + pub report_only: bool, 129 + } 130 + 131 + impl Policy { 132 + /// Look up the source list for a directive, falling back to default-src 133 + /// for fetch directives. 134 + fn source_list(&self, directive_type: DirectiveType) -> Option<&[SourceExpression]> { 135 + // First try the specific directive. 136 + for d in &self.directives { 137 + if d.directive_type == directive_type { 138 + return Some(&d.sources); 139 + } 140 + } 141 + // Fall back to default-src for fetch directives. 142 + if directive_type.has_default_fallback() { 143 + for d in &self.directives { 144 + if d.directive_type == DirectiveType::DefaultSrc { 145 + return Some(&d.sources); 146 + } 147 + } 148 + } 149 + Option::None 150 + } 151 + } 152 + 153 + // --------------------------------------------------------------------------- 154 + // PolicyList — multiple policies with intersection semantics 155 + // --------------------------------------------------------------------------- 156 + 157 + /// A set of CSP policies. All enforced policies must allow a resource 158 + /// (intersection semantics). 159 + #[derive(Debug, Clone, Default)] 160 + pub struct PolicyList { 161 + pub policies: Vec<Policy>, 162 + } 163 + 164 + impl PolicyList { 165 + pub fn new() -> Self { 166 + Self { 167 + policies: Vec::new(), 168 + } 169 + } 170 + 171 + pub fn is_empty(&self) -> bool { 172 + self.policies.is_empty() 173 + } 174 + 175 + /// Add a policy to the list. 176 + pub fn add(&mut self, policy: Policy) { 177 + self.policies.push(policy); 178 + } 179 + 180 + /// Check if a URL is allowed by the given directive type across all policies. 181 + /// Returns true if allowed (or if no policies define this directive). 182 + pub fn allows_url( 183 + &self, 184 + url: &Url, 185 + directive_type: DirectiveType, 186 + document_origin: &Origin, 187 + ) -> bool { 188 + if self.policies.is_empty() { 189 + return true; 190 + } 191 + 192 + for policy in &self.policies { 193 + if policy.report_only { 194 + // Report-only: log but don't block. 195 + if let Some(sources) = policy.source_list(directive_type) { 196 + if !url_matches_source_list(url, sources, document_origin) { 197 + log_violation(directive_type, &url.serialize(), policy.report_only); 198 + } 199 + } 200 + continue; 201 + } 202 + 203 + if let Some(sources) = policy.source_list(directive_type) { 204 + if !url_matches_source_list(url, sources, document_origin) { 205 + log_violation(directive_type, &url.serialize(), policy.report_only); 206 + return false; 207 + } 208 + } 209 + // If no directive found (and no default-src), the policy doesn't 210 + // restrict this resource type — allow. 211 + } 212 + 213 + true 214 + } 215 + 216 + /// Check if inline content (script or style) is allowed. 217 + /// 218 + /// `content` is the raw inline text. `nonce` is the element's `nonce` 219 + /// attribute, if any. 220 + pub fn allows_inline( 221 + &self, 222 + directive_type: DirectiveType, 223 + content: &str, 224 + nonce: Option<&str>, 225 + ) -> bool { 226 + if self.policies.is_empty() { 227 + return true; 228 + } 229 + 230 + for policy in &self.policies { 231 + if policy.report_only { 232 + if let Some(sources) = policy.source_list(directive_type) { 233 + if !inline_matches_source_list(sources, content, nonce) { 234 + log_violation(directive_type, "<inline>", policy.report_only); 235 + } 236 + } 237 + continue; 238 + } 239 + 240 + if let Some(sources) = policy.source_list(directive_type) { 241 + if !inline_matches_source_list(sources, content, nonce) { 242 + log_violation(directive_type, "<inline>", policy.report_only); 243 + return false; 244 + } 245 + } 246 + } 247 + 248 + true 249 + } 250 + 251 + /// Check if `eval()` is allowed by script-src. 252 + pub fn allows_eval(&self) -> bool { 253 + if self.policies.is_empty() { 254 + return true; 255 + } 256 + 257 + for policy in &self.policies { 258 + if policy.report_only { 259 + if let Some(sources) = policy.source_list(DirectiveType::ScriptSrc) { 260 + if !sources.contains(&SourceExpression::UnsafeEval) { 261 + log_violation(DirectiveType::ScriptSrc, "eval()", policy.report_only); 262 + } 263 + } 264 + continue; 265 + } 266 + 267 + if let Some(sources) = policy.source_list(DirectiveType::ScriptSrc) { 268 + if !sources.contains(&SourceExpression::UnsafeEval) { 269 + log_violation(DirectiveType::ScriptSrc, "eval()", policy.report_only); 270 + return false; 271 + } 272 + } 273 + } 274 + 275 + true 276 + } 277 + } 278 + 279 + // --------------------------------------------------------------------------- 280 + // Parsing 281 + // --------------------------------------------------------------------------- 282 + 283 + /// Parse a CSP header value into a Policy. 284 + /// 285 + /// The header value is a semicolon-separated list of directives. 286 + /// Each directive is a directive name followed by space-separated source expressions. 287 + pub fn parse_policy(header_value: &str, report_only: bool) -> Policy { 288 + let mut directives = Vec::new(); 289 + let mut seen_types = Vec::new(); 290 + 291 + for part in header_value.split(';') { 292 + let part = part.trim(); 293 + if part.is_empty() { 294 + continue; 295 + } 296 + 297 + let mut tokens = part.split_whitespace(); 298 + let name = match tokens.next() { 299 + Some(n) => n.to_ascii_lowercase(), 300 + Option::None => continue, 301 + }; 302 + 303 + let directive_type = match DirectiveType::parse(&name) { 304 + Some(dt) => dt, 305 + Option::None => continue, // Unknown directives are ignored. 306 + }; 307 + 308 + // Per spec, duplicate directives are ignored (first wins). 309 + if seen_types.contains(&directive_type) { 310 + continue; 311 + } 312 + seen_types.push(directive_type); 313 + 314 + let sources: Vec<SourceExpression> = tokens.filter_map(parse_source_expression).collect(); 315 + 316 + directives.push(Directive { 317 + directive_type, 318 + sources, 319 + }); 320 + } 321 + 322 + Policy { 323 + directives, 324 + report_only, 325 + } 326 + } 327 + 328 + /// Parse CSP policies from HTTP response headers. 329 + /// 330 + /// Handles both `Content-Security-Policy` and `Content-Security-Policy-Report-Only`. 331 + /// Multiple header values result in multiple policies (intersection semantics). 332 + pub fn parse_from_headers(headers: &[(String, String)]) -> PolicyList { 333 + let mut list = PolicyList::new(); 334 + 335 + for (name, value) in headers { 336 + let name_lower = name.to_ascii_lowercase(); 337 + if name_lower == "content-security-policy" { 338 + list.add(parse_policy(value, false)); 339 + } else if name_lower == "content-security-policy-report-only" { 340 + list.add(parse_policy(value, true)); 341 + } 342 + } 343 + 344 + list 345 + } 346 + 347 + /// Parse a single source expression token. 348 + fn parse_source_expression(token: &str) -> Option<SourceExpression> { 349 + let lower = token.to_ascii_lowercase(); 350 + 351 + // Keyword sources (quoted). 352 + if lower == "'none'" { 353 + return Some(SourceExpression::None); 354 + } 355 + if lower == "'self'" { 356 + return Some(SourceExpression::Self_); 357 + } 358 + if lower == "'unsafe-inline'" { 359 + return Some(SourceExpression::UnsafeInline); 360 + } 361 + if lower == "'unsafe-eval'" { 362 + return Some(SourceExpression::UnsafeEval); 363 + } 364 + 365 + // Nonce source: 'nonce-<base64>' 366 + if let Some(rest) = strip_keyword_prefix(&lower, "'nonce-") { 367 + if let Some(nonce_value) = rest.strip_suffix('\'') { 368 + if !nonce_value.is_empty() { 369 + // Extract from original token to preserve case of the nonce value. 370 + let orig_nonce = &token[7..token.len() - 1]; 371 + return Some(SourceExpression::Nonce(orig_nonce.to_string())); 372 + } 373 + } 374 + return Option::None; 375 + } 376 + 377 + // Hash source: 'sha256-<base64>', 'sha384-<base64>', 'sha512-<base64>' 378 + for (prefix, algo) in &[ 379 + ("'sha256-", HashAlgorithm::Sha256), 380 + ("'sha384-", HashAlgorithm::Sha384), 381 + ("'sha512-", HashAlgorithm::Sha512), 382 + ] { 383 + if let Some(rest) = strip_keyword_prefix(&lower, prefix) { 384 + if rest.strip_suffix('\'').is_some() { 385 + // Use original case for the base64 value. 386 + let orig_b64 = &token[prefix.len()..token.len() - 1]; 387 + if let Ok(hash_bytes) = base64_decode(orig_b64) { 388 + return Some(SourceExpression::Hash(*algo, hash_bytes)); 389 + } 390 + } 391 + return Option::None; 392 + } 393 + } 394 + 395 + // Scheme source: e.g., `https:`, `data:`, `blob:` 396 + if lower.ends_with(':') && !lower.contains('/') { 397 + let scheme = lower[..lower.len() - 1].to_string(); 398 + if !scheme.is_empty() 399 + && scheme 400 + .chars() 401 + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') 402 + { 403 + return Some(SourceExpression::Scheme(scheme)); 404 + } 405 + } 406 + 407 + // Host source: [scheme://]host[:port] 408 + Some(parse_host_source(token)) 409 + } 410 + 411 + /// Parse a host source expression. 412 + fn parse_host_source(token: &str) -> SourceExpression { 413 + let mut remaining = token; 414 + let mut scheme = Option::None; 415 + 416 + // Extract optional scheme. 417 + if let Some(idx) = remaining.find("://") { 418 + scheme = Some(remaining[..idx].to_ascii_lowercase()); 419 + remaining = &remaining[idx + 3..]; 420 + } 421 + 422 + // Extract optional port. 423 + let (host_part, port) = if let Some(colon_idx) = remaining.rfind(':') { 424 + let port_str = &remaining[colon_idx + 1..]; 425 + if let Ok(p) = port_str.parse::<u16>() { 426 + (&remaining[..colon_idx], Some(p)) 427 + } else { 428 + (remaining, Option::None) 429 + } 430 + } else { 431 + (remaining, Option::None) 432 + }; 433 + 434 + let host = host_part.to_ascii_lowercase(); 435 + 436 + SourceExpression::Host(HostSource { scheme, host, port }) 437 + } 438 + 439 + /// Helper: strip a prefix from a string, case-insensitively. 440 + fn strip_keyword_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { 441 + if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) { 442 + Some(&s[prefix.len()..]) 443 + } else { 444 + Option::None 445 + } 446 + } 447 + 448 + // --------------------------------------------------------------------------- 449 + // Source matching 450 + // --------------------------------------------------------------------------- 451 + 452 + /// Check if a URL matches any source expression in a source list. 453 + fn url_matches_source_list( 454 + url: &Url, 455 + sources: &[SourceExpression], 456 + document_origin: &Origin, 457 + ) -> bool { 458 + // 'none' means nothing is allowed. 459 + if sources.len() == 1 && sources[0] == SourceExpression::None { 460 + return false; 461 + } 462 + 463 + for source in sources { 464 + if url_matches_source(url, source, document_origin) { 465 + return true; 466 + } 467 + } 468 + 469 + false 470 + } 471 + 472 + /// Check if a URL matches a single source expression. 473 + fn url_matches_source(url: &Url, source: &SourceExpression, document_origin: &Origin) -> bool { 474 + match source { 475 + SourceExpression::None => false, 476 + SourceExpression::Self_ => { 477 + let resource_origin = url.origin(); 478 + document_origin.same_origin(&resource_origin) 479 + } 480 + SourceExpression::UnsafeInline | SourceExpression::UnsafeEval => false, 481 + SourceExpression::Nonce(_) | SourceExpression::Hash(_, _) => false, 482 + SourceExpression::Scheme(scheme) => url.scheme().eq_ignore_ascii_case(scheme), 483 + SourceExpression::Host(host_src) => host_source_matches(url, host_src), 484 + } 485 + } 486 + 487 + /// Check if a URL matches a host source expression. 488 + fn host_source_matches(url: &Url, host_src: &HostSource) -> bool { 489 + // Check scheme if specified. 490 + if let Some(ref scheme) = host_src.scheme { 491 + if !url.scheme().eq_ignore_ascii_case(scheme) { 492 + return false; 493 + } 494 + } 495 + 496 + // Check host. 497 + let url_host = match url.host_str() { 498 + Some(h) => h.to_ascii_lowercase(), 499 + None => return false, 500 + }; 501 + let pattern = &host_src.host; 502 + 503 + if let Some(suffix) = pattern.strip_prefix("*.") { 504 + // Wildcard: URL host must end with the suffix or equal it. 505 + let suffix_lower = suffix.to_ascii_lowercase(); 506 + if url_host == suffix_lower { 507 + return check_port(url, host_src.port); 508 + } 509 + if url_host.ends_with(&format!(".{suffix_lower}")) { 510 + return check_port(url, host_src.port); 511 + } 512 + return false; 513 + } 514 + 515 + // Exact host match. 516 + if !url_host.eq_ignore_ascii_case(pattern) { 517 + return false; 518 + } 519 + 520 + check_port(url, host_src.port) 521 + } 522 + 523 + /// Check port restriction. 524 + fn check_port(url: &Url, required_port: Option<u16>) -> bool { 525 + match required_port { 526 + Option::None => true, 527 + Some(port) => { 528 + let url_port = url.port().unwrap_or_else(|| default_port(url.scheme())); 529 + url_port == port 530 + } 531 + } 532 + } 533 + 534 + /// Return the default port for common schemes. 535 + fn default_port(scheme: &str) -> u16 { 536 + match scheme { 537 + "http" => 80, 538 + "https" => 443, 539 + "ftp" => 21, 540 + _ => 0, 541 + } 542 + } 543 + 544 + /// Check if inline content matches a source list. 545 + /// 546 + /// Inline content is allowed if: 547 + /// - `'unsafe-inline'` is present (and no nonce/hash sources override it) 548 + /// - A matching nonce is present 549 + /// - A matching hash is present 550 + fn inline_matches_source_list( 551 + sources: &[SourceExpression], 552 + content: &str, 553 + nonce: Option<&str>, 554 + ) -> bool { 555 + if sources.len() == 1 && sources[0] == SourceExpression::None { 556 + return false; 557 + } 558 + 559 + let has_nonce_or_hash = sources 560 + .iter() 561 + .any(|s| matches!(s, SourceExpression::Nonce(_) | SourceExpression::Hash(_, _))); 562 + 563 + // Check nonce match. 564 + if let Some(nonce_value) = nonce { 565 + for source in sources { 566 + if let SourceExpression::Nonce(ref expected) = source { 567 + if nonce_value == expected { 568 + return true; 569 + } 570 + } 571 + } 572 + } 573 + 574 + // Check hash match. 575 + for source in sources { 576 + if let SourceExpression::Hash(algo, ref expected_hash) = source { 577 + let computed = compute_hash(*algo, content.as_bytes()); 578 + if computed == *expected_hash { 579 + return true; 580 + } 581 + } 582 + } 583 + 584 + // 'unsafe-inline' is only effective when no nonce or hash sources are present. 585 + // Per CSP2: if a nonce or hash is specified, 'unsafe-inline' is ignored. 586 + if !has_nonce_or_hash { 587 + for source in sources { 588 + if *source == SourceExpression::UnsafeInline { 589 + return true; 590 + } 591 + } 592 + } 593 + 594 + false 595 + } 596 + 597 + /// Compute a hash of the given data with the specified algorithm. 598 + fn compute_hash(algo: HashAlgorithm, data: &[u8]) -> Vec<u8> { 599 + match algo { 600 + HashAlgorithm::Sha256 => sha2::sha256(data).to_vec(), 601 + HashAlgorithm::Sha384 => sha2::sha384(data).to_vec(), 602 + HashAlgorithm::Sha512 => sha2::sha512(data).to_vec(), 603 + } 604 + } 605 + 606 + // --------------------------------------------------------------------------- 607 + // Violation reporting 608 + // --------------------------------------------------------------------------- 609 + 610 + fn log_violation(directive_type: DirectiveType, resource: &str, report_only: bool) { 611 + let mode = if report_only { 612 + "report-only" 613 + } else { 614 + "enforced" 615 + }; 616 + let directive_name = match directive_type { 617 + DirectiveType::DefaultSrc => "default-src", 618 + DirectiveType::ScriptSrc => "script-src", 619 + DirectiveType::StyleSrc => "style-src", 620 + DirectiveType::ImgSrc => "img-src", 621 + DirectiveType::FontSrc => "font-src", 622 + DirectiveType::ConnectSrc => "connect-src", 623 + DirectiveType::FrameSrc => "frame-src", 624 + DirectiveType::MediaSrc => "media-src", 625 + DirectiveType::ObjectSrc => "object-src", 626 + DirectiveType::BaseUri => "base-uri", 627 + DirectiveType::FormAction => "form-action", 628 + }; 629 + eprintln!("[csp] {mode} violation: {directive_name} blocked {resource}"); 630 + } 631 + 632 + // --------------------------------------------------------------------------- 633 + // Meta tag extraction 634 + // --------------------------------------------------------------------------- 635 + 636 + /// Extract CSP policies from `<meta http-equiv="Content-Security-Policy">` tags. 637 + /// 638 + /// Note: per spec, `Content-Security-Policy-Report-Only` is NOT valid in meta tags. 639 + pub fn extract_meta_csp(doc: &we_dom::Document) -> PolicyList { 640 + use we_dom::NodeId; 641 + 642 + let mut list = PolicyList::new(); 643 + for i in 0..doc.len() { 644 + let node_id = NodeId::from_index(i); 645 + if doc.tag_name(node_id) == Some("meta") { 646 + if let Some(http_equiv) = doc.get_attribute(node_id, "http-equiv") { 647 + if http_equiv.eq_ignore_ascii_case("Content-Security-Policy") { 648 + if let Some(content) = doc.get_attribute(node_id, "content") { 649 + list.add(parse_policy(content, false)); 650 + } 651 + } 652 + } 653 + } 654 + } 655 + list 656 + } 657 + 658 + // --------------------------------------------------------------------------- 659 + // Convenience: map ResourceRequestType to CSP directive 660 + // --------------------------------------------------------------------------- 661 + 662 + /// Map a resource request type to the appropriate CSP directive. 663 + pub fn directive_for_request_type( 664 + request_type: crate::loader::ResourceRequestType, 665 + ) -> Option<DirectiveType> { 666 + use crate::loader::ResourceRequestType; 667 + match request_type { 668 + ResourceRequestType::Script => Some(DirectiveType::ScriptSrc), 669 + ResourceRequestType::Stylesheet => Some(DirectiveType::StyleSrc), 670 + ResourceRequestType::Image => Some(DirectiveType::ImgSrc), 671 + ResourceRequestType::Font => Some(DirectiveType::FontSrc), 672 + ResourceRequestType::Fetch => Some(DirectiveType::ConnectSrc), 673 + ResourceRequestType::Navigation => Option::None, // CSP doesn't restrict navigation via fetch directives 674 + } 675 + } 676 + 677 + // --------------------------------------------------------------------------- 678 + // Tests 679 + // --------------------------------------------------------------------------- 680 + 681 + #[cfg(test)] 682 + mod tests { 683 + use super::*; 684 + 685 + // ----------------------------------------------------------------------- 686 + // Parsing 687 + // ----------------------------------------------------------------------- 688 + 689 + #[test] 690 + fn parse_empty_policy() { 691 + let policy = parse_policy("", false); 692 + assert!(policy.directives.is_empty()); 693 + assert!(!policy.report_only); 694 + } 695 + 696 + #[test] 697 + fn parse_single_directive() { 698 + let policy = parse_policy("default-src 'self'", false); 699 + assert_eq!(policy.directives.len(), 1); 700 + assert_eq!( 701 + policy.directives[0].directive_type, 702 + DirectiveType::DefaultSrc 703 + ); 704 + assert_eq!(policy.directives[0].sources.len(), 1); 705 + assert_eq!(policy.directives[0].sources[0], SourceExpression::Self_); 706 + } 707 + 708 + #[test] 709 + fn parse_multiple_directives() { 710 + let policy = parse_policy( 711 + "default-src 'none'; script-src 'self'; img-src https:", 712 + false, 713 + ); 714 + assert_eq!(policy.directives.len(), 3); 715 + } 716 + 717 + #[test] 718 + fn parse_duplicate_directive_first_wins() { 719 + let policy = parse_policy("script-src 'self'; script-src 'none'", false); 720 + assert_eq!(policy.directives.len(), 1); 721 + assert_eq!(policy.directives[0].sources[0], SourceExpression::Self_); 722 + } 723 + 724 + #[test] 725 + fn parse_unknown_directive_ignored() { 726 + let policy = parse_policy("unknown-directive 'self'; script-src 'self'", false); 727 + assert_eq!(policy.directives.len(), 1); 728 + assert_eq!( 729 + policy.directives[0].directive_type, 730 + DirectiveType::ScriptSrc 731 + ); 732 + } 733 + 734 + #[test] 735 + fn parse_report_only() { 736 + let policy = parse_policy("default-src 'self'", true); 737 + assert!(policy.report_only); 738 + } 739 + 740 + // ----------------------------------------------------------------------- 741 + // Source expression parsing 742 + // ----------------------------------------------------------------------- 743 + 744 + #[test] 745 + fn parse_none_source() { 746 + let s = parse_source_expression("'none'").unwrap(); 747 + assert_eq!(s, SourceExpression::None); 748 + } 749 + 750 + #[test] 751 + fn parse_self_source() { 752 + let s = parse_source_expression("'self'").unwrap(); 753 + assert_eq!(s, SourceExpression::Self_); 754 + } 755 + 756 + #[test] 757 + fn parse_unsafe_inline() { 758 + let s = parse_source_expression("'unsafe-inline'").unwrap(); 759 + assert_eq!(s, SourceExpression::UnsafeInline); 760 + } 761 + 762 + #[test] 763 + fn parse_unsafe_eval() { 764 + let s = parse_source_expression("'unsafe-eval'").unwrap(); 765 + assert_eq!(s, SourceExpression::UnsafeEval); 766 + } 767 + 768 + #[test] 769 + fn parse_nonce_source() { 770 + let s = parse_source_expression("'nonce-abc123'").unwrap(); 771 + assert_eq!(s, SourceExpression::Nonce("abc123".to_string())); 772 + } 773 + 774 + #[test] 775 + fn parse_hash_sha256() { 776 + // SHA-256 hash of empty string in base64 777 + let s = parse_source_expression("'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='"); 778 + assert!(s.is_some()); 779 + if let Some(SourceExpression::Hash(algo, _)) = s { 780 + assert_eq!(algo, HashAlgorithm::Sha256); 781 + } else { 782 + panic!("expected Hash source"); 783 + } 784 + } 785 + 786 + #[test] 787 + fn parse_scheme_source() { 788 + let s = parse_source_expression("https:").unwrap(); 789 + assert_eq!(s, SourceExpression::Scheme("https".to_string())); 790 + } 791 + 792 + #[test] 793 + fn parse_data_scheme_source() { 794 + let s = parse_source_expression("data:").unwrap(); 795 + assert_eq!(s, SourceExpression::Scheme("data".to_string())); 796 + } 797 + 798 + #[test] 799 + fn parse_host_source_simple() { 800 + let s = parse_source_expression("example.com").unwrap(); 801 + if let SourceExpression::Host(h) = s { 802 + assert_eq!(h.host, "example.com"); 803 + assert!(h.scheme.is_none()); 804 + assert!(h.port.is_none()); 805 + } else { 806 + panic!("expected Host source"); 807 + } 808 + } 809 + 810 + #[test] 811 + fn parse_host_source_with_scheme() { 812 + let s = parse_source_expression("https://example.com").unwrap(); 813 + if let SourceExpression::Host(h) = s { 814 + assert_eq!(h.scheme.as_deref(), Some("https")); 815 + assert_eq!(h.host, "example.com"); 816 + } else { 817 + panic!("expected Host source"); 818 + } 819 + } 820 + 821 + #[test] 822 + fn parse_host_source_with_port() { 823 + let s = parse_source_expression("example.com:8080").unwrap(); 824 + if let SourceExpression::Host(h) = s { 825 + assert_eq!(h.host, "example.com"); 826 + assert_eq!(h.port, Some(8080)); 827 + } else { 828 + panic!("expected Host source"); 829 + } 830 + } 831 + 832 + #[test] 833 + fn parse_host_source_wildcard() { 834 + let s = parse_source_expression("*.example.com").unwrap(); 835 + if let SourceExpression::Host(h) = s { 836 + assert_eq!(h.host, "*.example.com"); 837 + } else { 838 + panic!("expected Host source"); 839 + } 840 + } 841 + 842 + #[test] 843 + fn parse_host_source_full() { 844 + let s = parse_source_expression("https://example.com:443").unwrap(); 845 + if let SourceExpression::Host(h) = s { 846 + assert_eq!(h.scheme.as_deref(), Some("https")); 847 + assert_eq!(h.host, "example.com"); 848 + assert_eq!(h.port, Some(443)); 849 + } else { 850 + panic!("expected Host source"); 851 + } 852 + } 853 + 854 + // ----------------------------------------------------------------------- 855 + // Keyword case insensitivity 856 + // ----------------------------------------------------------------------- 857 + 858 + #[test] 859 + fn parse_keywords_case_insensitive() { 860 + assert_eq!( 861 + parse_source_expression("'SELF'").unwrap(), 862 + SourceExpression::Self_ 863 + ); 864 + assert_eq!( 865 + parse_source_expression("'Unsafe-Inline'").unwrap(), 866 + SourceExpression::UnsafeInline 867 + ); 868 + assert_eq!( 869 + parse_source_expression("'UNSAFE-EVAL'").unwrap(), 870 + SourceExpression::UnsafeEval 871 + ); 872 + } 873 + 874 + // ----------------------------------------------------------------------- 875 + // URL matching 876 + // ----------------------------------------------------------------------- 877 + 878 + fn test_origin() -> Origin { 879 + Url::parse("https://example.com/page").unwrap().origin() 880 + } 881 + 882 + #[test] 883 + fn self_matches_same_origin() { 884 + let url = Url::parse("https://example.com/script.js").unwrap(); 885 + let origin = test_origin(); 886 + assert!(url_matches_source(&url, &SourceExpression::Self_, &origin)); 887 + } 888 + 889 + #[test] 890 + fn self_does_not_match_different_origin() { 891 + let url = Url::parse("https://other.com/script.js").unwrap(); 892 + let origin = test_origin(); 893 + assert!(!url_matches_source(&url, &SourceExpression::Self_, &origin)); 894 + } 895 + 896 + #[test] 897 + fn scheme_matches() { 898 + let url = Url::parse("https://any.com/path").unwrap(); 899 + let origin = test_origin(); 900 + assert!(url_matches_source( 901 + &url, 902 + &SourceExpression::Scheme("https".to_string()), 903 + &origin, 904 + )); 905 + } 906 + 907 + #[test] 908 + fn scheme_does_not_match_different() { 909 + let url = Url::parse("http://any.com/path").unwrap(); 910 + let origin = test_origin(); 911 + assert!(!url_matches_source( 912 + &url, 913 + &SourceExpression::Scheme("https".to_string()), 914 + &origin, 915 + )); 916 + } 917 + 918 + #[test] 919 + fn host_exact_match() { 920 + let url = Url::parse("https://cdn.example.com/lib.js").unwrap(); 921 + let origin = test_origin(); 922 + let source = SourceExpression::Host(HostSource { 923 + scheme: None, 924 + host: "cdn.example.com".to_string(), 925 + port: None, 926 + }); 927 + assert!(url_matches_source(&url, &source, &origin)); 928 + } 929 + 930 + #[test] 931 + fn host_wildcard_match() { 932 + let url = Url::parse("https://cdn.example.com/lib.js").unwrap(); 933 + let origin = test_origin(); 934 + let source = SourceExpression::Host(HostSource { 935 + scheme: None, 936 + host: "*.example.com".to_string(), 937 + port: None, 938 + }); 939 + assert!(url_matches_source(&url, &source, &origin)); 940 + } 941 + 942 + #[test] 943 + fn host_wildcard_does_not_match_base_domain() { 944 + // *.example.com should match sub.example.com but also example.com per CSP spec 945 + let url = Url::parse("https://example.com/lib.js").unwrap(); 946 + let origin = test_origin(); 947 + let source = SourceExpression::Host(HostSource { 948 + scheme: None, 949 + host: "*.example.com".to_string(), 950 + port: None, 951 + }); 952 + assert!(url_matches_source(&url, &source, &origin)); 953 + } 954 + 955 + #[test] 956 + fn host_with_scheme_restriction() { 957 + let url = Url::parse("http://example.com/script.js").unwrap(); 958 + let origin = test_origin(); 959 + let source = SourceExpression::Host(HostSource { 960 + scheme: Some("https".to_string()), 961 + host: "example.com".to_string(), 962 + port: None, 963 + }); 964 + assert!(!url_matches_source(&url, &source, &origin)); 965 + } 966 + 967 + #[test] 968 + fn host_with_port_restriction() { 969 + let url = Url::parse("https://example.com:8080/script.js").unwrap(); 970 + let origin = test_origin(); 971 + let source = SourceExpression::Host(HostSource { 972 + scheme: None, 973 + host: "example.com".to_string(), 974 + port: Some(443), 975 + }); 976 + assert!(!url_matches_source(&url, &source, &origin)); 977 + } 978 + 979 + #[test] 980 + fn none_blocks_everything() { 981 + let url = Url::parse("https://example.com/script.js").unwrap(); 982 + let origin = test_origin(); 983 + assert!(!url_matches_source_list( 984 + &url, 985 + &[SourceExpression::None], 986 + &origin, 987 + )); 988 + } 989 + 990 + // ----------------------------------------------------------------------- 991 + // Inline matching 992 + // ----------------------------------------------------------------------- 993 + 994 + #[test] 995 + fn inline_blocked_by_default() { 996 + let sources = vec![SourceExpression::Self_]; 997 + assert!(!inline_matches_source_list(&sources, "alert(1)", None)); 998 + } 999 + 1000 + #[test] 1001 + fn inline_allowed_by_unsafe_inline() { 1002 + let sources = vec![SourceExpression::Self_, SourceExpression::UnsafeInline]; 1003 + assert!(inline_matches_source_list(&sources, "alert(1)", None)); 1004 + } 1005 + 1006 + #[test] 1007 + fn inline_allowed_by_nonce() { 1008 + let sources = vec![ 1009 + SourceExpression::Self_, 1010 + SourceExpression::Nonce("abc123".to_string()), 1011 + ]; 1012 + assert!(inline_matches_source_list( 1013 + &sources, 1014 + "alert(1)", 1015 + Some("abc123"), 1016 + )); 1017 + } 1018 + 1019 + #[test] 1020 + fn inline_blocked_by_wrong_nonce() { 1021 + let sources = vec![ 1022 + SourceExpression::Self_, 1023 + SourceExpression::Nonce("abc123".to_string()), 1024 + ]; 1025 + assert!(!inline_matches_source_list( 1026 + &sources, 1027 + "alert(1)", 1028 + Some("wrong"), 1029 + )); 1030 + } 1031 + 1032 + #[test] 1033 + fn inline_allowed_by_hash() { 1034 + let content = "alert(1)"; 1035 + let hash = sha2::sha256(content.as_bytes()).to_vec(); 1036 + let sources = vec![ 1037 + SourceExpression::Self_, 1038 + SourceExpression::Hash(HashAlgorithm::Sha256, hash), 1039 + ]; 1040 + assert!(inline_matches_source_list(&sources, content, None)); 1041 + } 1042 + 1043 + #[test] 1044 + fn inline_blocked_by_wrong_hash() { 1045 + let content = "alert(1)"; 1046 + let sources = vec![ 1047 + SourceExpression::Self_, 1048 + SourceExpression::Hash(HashAlgorithm::Sha256, vec![0; 32]), 1049 + ]; 1050 + assert!(!inline_matches_source_list(&sources, content, None)); 1051 + } 1052 + 1053 + #[test] 1054 + fn unsafe_inline_ignored_when_nonce_present() { 1055 + // Per CSP2: 'unsafe-inline' is ignored if a nonce or hash is present. 1056 + let sources = vec![ 1057 + SourceExpression::UnsafeInline, 1058 + SourceExpression::Nonce("expected".to_string()), 1059 + ]; 1060 + // No nonce attribute, no matching nonce → blocked despite 'unsafe-inline'. 1061 + assert!(!inline_matches_source_list(&sources, "alert(1)", None)); 1062 + } 1063 + 1064 + #[test] 1065 + fn unsafe_inline_ignored_when_hash_present() { 1066 + let sources = vec![ 1067 + SourceExpression::UnsafeInline, 1068 + SourceExpression::Hash(HashAlgorithm::Sha256, vec![0; 32]), 1069 + ]; 1070 + // Hash doesn't match → blocked despite 'unsafe-inline'. 1071 + assert!(!inline_matches_source_list(&sources, "alert(1)", None)); 1072 + } 1073 + 1074 + // ----------------------------------------------------------------------- 1075 + // Eval checking 1076 + // ----------------------------------------------------------------------- 1077 + 1078 + #[test] 1079 + fn eval_blocked_by_default() { 1080 + let mut list = PolicyList::new(); 1081 + list.add(parse_policy("script-src 'self'", false)); 1082 + assert!(!list.allows_eval()); 1083 + } 1084 + 1085 + #[test] 1086 + fn eval_allowed_by_unsafe_eval() { 1087 + let mut list = PolicyList::new(); 1088 + list.add(parse_policy("script-src 'self' 'unsafe-eval'", false)); 1089 + assert!(list.allows_eval()); 1090 + } 1091 + 1092 + #[test] 1093 + fn eval_allowed_when_no_policy() { 1094 + let list = PolicyList::new(); 1095 + assert!(list.allows_eval()); 1096 + } 1097 + 1098 + #[test] 1099 + fn eval_falls_back_to_default_src() { 1100 + let mut list = PolicyList::new(); 1101 + list.add(parse_policy("default-src 'self' 'unsafe-eval'", false)); 1102 + assert!(list.allows_eval()); 1103 + } 1104 + 1105 + #[test] 1106 + fn eval_blocked_by_default_src_fallback() { 1107 + let mut list = PolicyList::new(); 1108 + list.add(parse_policy("default-src 'self'", false)); 1109 + assert!(!list.allows_eval()); 1110 + } 1111 + 1112 + // ----------------------------------------------------------------------- 1113 + // PolicyList intersection semantics 1114 + // ----------------------------------------------------------------------- 1115 + 1116 + #[test] 1117 + fn multiple_policies_intersection() { 1118 + let mut list = PolicyList::new(); 1119 + // Policy 1: allow scripts from self and cdn.example.com 1120 + list.add(parse_policy("script-src 'self' cdn.example.com", false)); 1121 + // Policy 2: allow scripts from self only 1122 + list.add(parse_policy("script-src 'self'", false)); 1123 + 1124 + let origin = test_origin(); 1125 + let self_url = Url::parse("https://example.com/script.js").unwrap(); 1126 + let cdn_url = Url::parse("https://cdn.example.com/lib.js").unwrap(); 1127 + 1128 + // Self is allowed by both policies. 1129 + assert!(list.allows_url(&self_url, DirectiveType::ScriptSrc, &origin)); 1130 + // CDN is allowed by policy 1 but blocked by policy 2. 1131 + assert!(!list.allows_url(&cdn_url, DirectiveType::ScriptSrc, &origin)); 1132 + } 1133 + 1134 + #[test] 1135 + fn report_only_does_not_block() { 1136 + let mut list = PolicyList::new(); 1137 + list.add(parse_policy("script-src 'none'", true)); 1138 + 1139 + let origin = test_origin(); 1140 + let url = Url::parse("https://example.com/script.js").unwrap(); 1141 + 1142 + // Report-only should not block. 1143 + assert!(list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); 1144 + } 1145 + 1146 + #[test] 1147 + fn enforced_and_report_only_together() { 1148 + let mut list = PolicyList::new(); 1149 + // Enforced policy: allow self 1150 + list.add(parse_policy("script-src 'self'", false)); 1151 + // Report-only: block everything (should not affect enforcement) 1152 + list.add(parse_policy("script-src 'none'", true)); 1153 + 1154 + let origin = test_origin(); 1155 + let url = Url::parse("https://example.com/script.js").unwrap(); 1156 + 1157 + // Should be allowed (enforced policy allows 'self', report-only doesn't block). 1158 + assert!(list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); 1159 + } 1160 + 1161 + // ----------------------------------------------------------------------- 1162 + // Default-src fallback 1163 + // ----------------------------------------------------------------------- 1164 + 1165 + #[test] 1166 + fn default_src_fallback_for_script() { 1167 + let mut list = PolicyList::new(); 1168 + list.add(parse_policy("default-src 'self'", false)); 1169 + 1170 + let origin = test_origin(); 1171 + let url = Url::parse("https://example.com/script.js").unwrap(); 1172 + assert!(list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); 1173 + } 1174 + 1175 + #[test] 1176 + fn specific_directive_overrides_default() { 1177 + let mut list = PolicyList::new(); 1178 + list.add(parse_policy("default-src 'self'; script-src 'none'", false)); 1179 + 1180 + let origin = test_origin(); 1181 + let url = Url::parse("https://example.com/script.js").unwrap(); 1182 + // script-src 'none' overrides default-src 'self'. 1183 + assert!(!list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); 1184 + } 1185 + 1186 + #[test] 1187 + fn no_directive_and_no_default_allows() { 1188 + let mut list = PolicyList::new(); 1189 + // Only img-src specified, no default-src. 1190 + list.add(parse_policy("img-src 'self'", false)); 1191 + 1192 + let origin = test_origin(); 1193 + let url = Url::parse("https://evil.com/script.js").unwrap(); 1194 + // No script-src and no default-src → not restricted by this policy. 1195 + assert!(list.allows_url(&url, DirectiveType::ScriptSrc, &origin)); 1196 + } 1197 + 1198 + // ----------------------------------------------------------------------- 1199 + // Default-src does NOT apply to base-uri and form-action 1200 + // ----------------------------------------------------------------------- 1201 + 1202 + #[test] 1203 + fn default_src_does_not_fallback_for_base_uri() { 1204 + let mut list = PolicyList::new(); 1205 + list.add(parse_policy("default-src 'none'", false)); 1206 + 1207 + let origin = test_origin(); 1208 + let url = Url::parse("https://evil.com/").unwrap(); 1209 + // base-uri doesn't fall back to default-src. 1210 + assert!(list.allows_url(&url, DirectiveType::BaseUri, &origin)); 1211 + } 1212 + 1213 + #[test] 1214 + fn default_src_does_not_fallback_for_form_action() { 1215 + let mut list = PolicyList::new(); 1216 + list.add(parse_policy("default-src 'none'", false)); 1217 + 1218 + let origin = test_origin(); 1219 + let url = Url::parse("https://evil.com/submit").unwrap(); 1220 + assert!(list.allows_url(&url, DirectiveType::FormAction, &origin)); 1221 + } 1222 + 1223 + // ----------------------------------------------------------------------- 1224 + // Parse from headers 1225 + // ----------------------------------------------------------------------- 1226 + 1227 + #[test] 1228 + fn parse_from_headers_enforced() { 1229 + let headers = vec![( 1230 + "Content-Security-Policy".to_string(), 1231 + "default-src 'self'".to_string(), 1232 + )]; 1233 + let list = parse_from_headers(&headers); 1234 + assert_eq!(list.policies.len(), 1); 1235 + assert!(!list.policies[0].report_only); 1236 + } 1237 + 1238 + #[test] 1239 + fn parse_from_headers_report_only() { 1240 + let headers = vec![( 1241 + "Content-Security-Policy-Report-Only".to_string(), 1242 + "default-src 'self'".to_string(), 1243 + )]; 1244 + let list = parse_from_headers(&headers); 1245 + assert_eq!(list.policies.len(), 1); 1246 + assert!(list.policies[0].report_only); 1247 + } 1248 + 1249 + #[test] 1250 + fn parse_from_headers_multiple() { 1251 + let headers = vec![ 1252 + ( 1253 + "Content-Security-Policy".to_string(), 1254 + "script-src 'self'".to_string(), 1255 + ), 1256 + ( 1257 + "Content-Security-Policy".to_string(), 1258 + "script-src https:".to_string(), 1259 + ), 1260 + ]; 1261 + let list = parse_from_headers(&headers); 1262 + assert_eq!(list.policies.len(), 2); 1263 + } 1264 + 1265 + #[test] 1266 + fn parse_from_headers_case_insensitive() { 1267 + let headers = vec![( 1268 + "content-security-policy".to_string(), 1269 + "default-src 'self'".to_string(), 1270 + )]; 1271 + let list = parse_from_headers(&headers); 1272 + assert_eq!(list.policies.len(), 1); 1273 + } 1274 + 1275 + // ----------------------------------------------------------------------- 1276 + // All directive types parse correctly 1277 + // ----------------------------------------------------------------------- 1278 + 1279 + #[test] 1280 + fn all_directive_types() { 1281 + let policy = parse_policy( 1282 + "default-src 'self'; script-src 'self'; style-src 'self'; \ 1283 + img-src 'self'; font-src 'self'; connect-src 'self'; \ 1284 + frame-src 'self'; media-src 'self'; object-src 'self'; \ 1285 + base-uri 'self'; form-action 'self'", 1286 + false, 1287 + ); 1288 + assert_eq!(policy.directives.len(), 11); 1289 + } 1290 + 1291 + // ----------------------------------------------------------------------- 1292 + // allows_inline integration 1293 + // ----------------------------------------------------------------------- 1294 + 1295 + #[test] 1296 + fn allows_inline_no_policy() { 1297 + let list = PolicyList::new(); 1298 + assert!(list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", None)); 1299 + } 1300 + 1301 + #[test] 1302 + fn allows_inline_blocked_by_self_only() { 1303 + let mut list = PolicyList::new(); 1304 + list.add(parse_policy("script-src 'self'", false)); 1305 + assert!(!list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", None)); 1306 + } 1307 + 1308 + #[test] 1309 + fn allows_inline_with_unsafe_inline() { 1310 + let mut list = PolicyList::new(); 1311 + list.add(parse_policy("script-src 'self' 'unsafe-inline'", false)); 1312 + assert!(list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", None)); 1313 + } 1314 + 1315 + #[test] 1316 + fn allows_inline_with_nonce_match() { 1317 + let mut list = PolicyList::new(); 1318 + list.add(parse_policy("script-src 'nonce-abc123'", false)); 1319 + assert!(list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", Some("abc123"),)); 1320 + } 1321 + 1322 + #[test] 1323 + fn allows_inline_with_nonce_mismatch() { 1324 + let mut list = PolicyList::new(); 1325 + list.add(parse_policy("script-src 'nonce-abc123'", false)); 1326 + assert!(!list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", Some("wrong"),)); 1327 + } 1328 + 1329 + // ----------------------------------------------------------------------- 1330 + // Hash integration test 1331 + // ----------------------------------------------------------------------- 1332 + 1333 + #[test] 1334 + fn hash_source_matches_content() { 1335 + // Compute the SHA-256 of "alert(1)" and encode in base64. 1336 + let hash = sha2::sha256(b"alert(1)"); 1337 + let b64 = base64_encode(&hash); 1338 + let policy_str = format!("script-src 'sha256-{b64}'"); 1339 + 1340 + let mut list = PolicyList::new(); 1341 + list.add(parse_policy(&policy_str, false)); 1342 + assert!(list.allows_inline(DirectiveType::ScriptSrc, "alert(1)", None)); 1343 + } 1344 + 1345 + #[test] 1346 + fn hash_source_does_not_match_different_content() { 1347 + let hash = sha2::sha256(b"alert(1)"); 1348 + let b64 = base64_encode(&hash); 1349 + let policy_str = format!("script-src 'sha256-{b64}'"); 1350 + 1351 + let mut list = PolicyList::new(); 1352 + list.add(parse_policy(&policy_str, false)); 1353 + assert!(!list.allows_inline(DirectiveType::ScriptSrc, "alert(2)", None,)); 1354 + } 1355 + 1356 + /// Simple base64 encoder for tests only. 1357 + fn base64_encode(data: &[u8]) -> String { 1358 + const ALPHABET: &[u8; 64] = 1359 + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 1360 + let mut result = String::new(); 1361 + let mut i = 0; 1362 + while i < data.len() { 1363 + let b0 = data[i] as u32; 1364 + let b1 = if i + 1 < data.len() { 1365 + data[i + 1] as u32 1366 + } else { 1367 + 0 1368 + }; 1369 + let b2 = if i + 2 < data.len() { 1370 + data[i + 2] as u32 1371 + } else { 1372 + 0 1373 + }; 1374 + let triple = (b0 << 16) | (b1 << 8) | b2; 1375 + 1376 + result.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char); 1377 + result.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char); 1378 + if i + 1 < data.len() { 1379 + result.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char); 1380 + } else { 1381 + result.push('='); 1382 + } 1383 + if i + 2 < data.len() { 1384 + result.push(ALPHABET[(triple & 0x3F) as usize] as char); 1385 + } else { 1386 + result.push('='); 1387 + } 1388 + i += 3; 1389 + } 1390 + result 1391 + } 1392 + 1393 + // ----------------------------------------------------------------------- 1394 + // directive_for_request_type 1395 + // ----------------------------------------------------------------------- 1396 + 1397 + #[test] 1398 + fn request_type_to_directive() { 1399 + use crate::loader::ResourceRequestType; 1400 + assert_eq!( 1401 + directive_for_request_type(ResourceRequestType::Script), 1402 + Some(DirectiveType::ScriptSrc), 1403 + ); 1404 + assert_eq!( 1405 + directive_for_request_type(ResourceRequestType::Stylesheet), 1406 + Some(DirectiveType::StyleSrc), 1407 + ); 1408 + assert_eq!( 1409 + directive_for_request_type(ResourceRequestType::Image), 1410 + Some(DirectiveType::ImgSrc), 1411 + ); 1412 + assert_eq!( 1413 + directive_for_request_type(ResourceRequestType::Font), 1414 + Some(DirectiveType::FontSrc), 1415 + ); 1416 + assert_eq!( 1417 + directive_for_request_type(ResourceRequestType::Fetch), 1418 + Some(DirectiveType::ConnectSrc), 1419 + ); 1420 + assert_eq!( 1421 + directive_for_request_type(ResourceRequestType::Navigation), 1422 + None, 1423 + ); 1424 + } 1425 + }
+1
crates/browser/src/lib.rs
··· 1 1 //! Event loop, resource loading, navigation, UI chrome. 2 2 3 + pub mod csp; 3 4 pub mod css_loader; 4 5 pub mod font_loader; 5 6 pub mod img_loader;
+63
crates/browser/src/loader.rs
··· 6 6 7 7 use std::fmt; 8 8 9 + use crate::csp::{self, PolicyList}; 9 10 use we_encoding::sniff::sniff_encoding; 10 11 use we_encoding::Encoding; 11 12 use we_net::client::{ClientError, HttpClient}; ··· 35 36 Encoding(String), 36 37 /// Blocked by Same-Origin Policy. 37 38 CrossOriginBlocked { url: String }, 39 + /// Blocked by Content Security Policy. 40 + CspBlocked { url: String, directive: String }, 38 41 } 39 42 40 43 impl fmt::Display for LoadError { ··· 48 51 Self::Encoding(s) => write!(f, "encoding error: {s}"), 49 52 Self::CrossOriginBlocked { url } => { 50 53 write!(f, "cross-origin request blocked: {url}") 54 + } 55 + Self::CspBlocked { url, directive } => { 56 + write!(f, "blocked by CSP {directive}: {url}") 51 57 } 52 58 } 53 59 } ··· 119 125 document_url: Option<Url>, 120 126 /// The active referrer policy for this document. 121 127 referrer_policy: ReferrerPolicy, 128 + /// Active Content Security Policies for this document. 129 + csp: PolicyList, 122 130 } 123 131 124 132 impl ResourceLoader { ··· 129 137 preflight_cache: PreflightCache::new(), 130 138 document_url: None, 131 139 referrer_policy: ReferrerPolicy::default(), 140 + csp: PolicyList::new(), 132 141 } 133 142 } 134 143 ··· 181 190 } 182 191 } 183 192 193 + /// Set the Content Security Policy for this document. 194 + pub fn set_csp(&mut self, csp: PolicyList) { 195 + self.csp = csp; 196 + } 197 + 198 + /// Get a reference to the active CSP policies. 199 + pub fn csp(&self) -> &PolicyList { 200 + &self.csp 201 + } 202 + 203 + /// Parse CSP from HTTP response headers and add to the policy list. 204 + pub fn update_csp_from_response(&mut self, response: &HttpResponse) { 205 + let headers: Vec<(String, String)> = response 206 + .headers 207 + .iter() 208 + .map(|(n, v)| (n.to_string(), v.to_string())) 209 + .collect(); 210 + let new_policies = csp::parse_from_headers(&headers); 211 + for policy in new_policies.policies { 212 + self.csp.add(policy); 213 + } 214 + } 215 + 216 + /// Check if a URL is allowed by CSP for the given request type. 217 + /// Returns Ok(()) if allowed, Err(LoadError::CspBlocked) if blocked. 218 + fn check_csp(&self, url: &Url, request_type: ResourceRequestType) -> Result<(), LoadError> { 219 + if self.csp.is_empty() { 220 + return Ok(()); 221 + } 222 + 223 + let directive = match csp::directive_for_request_type(request_type) { 224 + Some(d) => d, 225 + None => return Ok(()), // Navigation not restricted by fetch directives. 226 + }; 227 + 228 + let document_origin = match &self.document_url { 229 + Some(doc_url) => doc_url.origin(), 230 + None => return Ok(()), // No document URL means no CSP enforcement. 231 + }; 232 + 233 + if self.csp.allows_url(url, directive, &document_origin) { 234 + Ok(()) 235 + } else { 236 + Err(LoadError::CspBlocked { 237 + url: url.serialize(), 238 + directive: format!("{directive:?}"), 239 + }) 240 + } 241 + } 242 + 184 243 /// Fetch a resource at the given URL. 185 244 /// 186 245 /// Determines the resource type from the HTTP Content-Type header, decodes ··· 203 262 self.add_referer_header(&mut headers, url, None); 204 263 let response = self.client.request(Method::Get, url, &headers, None)?; 205 264 self.update_policy_from_response(&response); 265 + self.update_csp_from_response(&response); 206 266 207 267 // Check for HTTP error status codes 208 268 if response.status_code >= 400 { ··· 344 404 credentials_mode: CredentialsMode, 345 405 element_policy: Option<ReferrerPolicy>, 346 406 ) -> Result<Resource, LoadError> { 407 + // Check CSP before any fetch. 408 + self.check_csp(url, request_type)?; 409 + 347 410 // data: and about: URLs are always allowed (local, no network). 348 411 if url.scheme() == "data" || url.scheme() == "about" { 349 412 return self.fetch(url);
+17
crates/browser/src/main.rs
··· 1 1 use std::cell::RefCell; 2 2 use std::collections::HashMap; 3 3 4 + use we_browser::csp::{self, PolicyList}; 4 5 use we_browser::css_loader::collect_stylesheets; 5 6 use we_browser::font_loader::load_web_fonts; 6 7 use we_browser::img_loader::{collect_images, ImageStore}; ··· 344 345 base_url: Url, 345 346 /// Referrer policy from the HTTP Referrer-Policy response header, if any. 346 347 http_referrer_policy: Option<ReferrerPolicy>, 348 + /// CSP policies from HTTP response headers. 349 + http_csp: PolicyList, 347 350 } 348 351 349 352 /// Load content from a command-line argument. ··· 367 370 } else { 368 371 None 369 372 }; 373 + // Capture CSP policies from the HTTP response. 374 + let http_csp = loader.csp().clone(); 370 375 return LoadedHtml { 371 376 text, 372 377 base_url, 373 378 http_referrer_policy: http_policy, 379 + http_csp, 374 380 }; 375 381 } 376 382 Ok(_) => { ··· 396 402 text: content, 397 403 base_url, 398 404 http_referrer_policy: None, 405 + http_csp: PolicyList::new(), 399 406 } 400 407 } 401 408 Err(e) => error_page(&format!("Error reading {arg}: {e}")), ··· 424 431 text: html, 425 432 base_url, 426 433 http_referrer_policy: None, 434 + http_csp: PolicyList::new(), 427 435 } 428 436 } 429 437 ··· 455 463 // Determine the referrer policy. Priority: meta tag > HTTP header > default. 456 464 let meta_policy = extract_meta_referrer_policy(&doc); 457 465 466 + // Merge CSP policies: HTTP headers + meta tags. 467 + let meta_csp = csp::extract_meta_csp(&doc); 468 + let mut csp_policies = loaded.http_csp; 469 + for policy in meta_csp.policies { 470 + csp_policies.add(policy); 471 + } 472 + 458 473 // Execute <script> elements. Scripts may modify the DOM, so this must 459 474 // run before collecting CSS and images (which depend on DOM structure). 460 475 let mut loader = ResourceLoader::new(); 461 476 loader.set_document_url(&loaded.base_url); 477 + loader.set_csp(csp_policies); 462 478 if let Some(policy) = meta_policy { 463 479 loader.set_referrer_policy(policy); 464 480 } else if let Some(policy) = loaded.http_referrer_policy { ··· 507 523 text: ABOUT_BLANK_HTML.to_string(), 508 524 base_url: Url::parse("about:blank").expect("about:blank is always valid"), 509 525 http_referrer_policy: None, 526 + http_csp: PolicyList::new(), 510 527 }, 511 528 }; 512 529
+13 -1
crates/browser/src/script_loader.rs
··· 3 3 //! Walks the DOM for `<script>` elements, fetches external scripts, and 4 4 //! executes them in a shared JS VM with DOM access. 5 5 6 + use crate::csp::DirectiveType; 6 7 use crate::loader::{Resource, ResourceLoader, ResourceRequestType}; 7 8 use we_dom::{Document, NodeId}; 8 9 use we_js::compiler; ··· 25 26 type_attr: Option<String>, 26 27 /// Element-level referrer policy override (`referrerpolicy` attribute). 27 28 referrer_policy: Option<ReferrerPolicy>, 29 + /// The `nonce` attribute value, if any (for CSP nonce matching). 30 + nonce: Option<String>, 28 31 } 29 32 30 33 /// Returns true if a script's `type` attribute indicates it should execute. ··· 76 79 let referrer_policy = doc 77 80 .get_attribute(node, "referrerpolicy") 78 81 .and_then(ReferrerPolicy::parse); 82 + let nonce = doc.get_attribute(node, "nonce").map(|s| s.to_string()); 79 83 80 84 let text = if src.is_none() { 81 85 let content = collect_text_content(doc, node); ··· 95 99 async_attr, 96 100 type_attr, 97 101 referrer_policy, 102 + nonce, 98 103 } 99 104 } 100 105 ··· 219 224 // Separate into immediate (sync + async) and deferred scripts. 220 225 let mut deferred_sources: Vec<(String, String)> = Vec::new(); 221 226 227 + let csp = loader.csp().clone(); 228 + 222 229 for (_node, info) in &scripts { 223 230 // Resolve the script source text. 224 231 let (source, label) = if let Some(ref src) = info.src { 225 - // External script: fetch it. 232 + // External script: fetch it (CSP URL check happens in fetch_subresource). 226 233 match fetch_script_text(loader, src, base_url, document_origin, info.referrer_policy) { 227 234 Some(text) => (text, src.clone()), 228 235 None => continue, 229 236 } 230 237 } else if let Some(ref text) = info.text { 238 + // Check CSP for inline scripts. 239 + if !csp.allows_inline(DirectiveType::ScriptSrc, text, info.nonce.as_deref()) { 240 + eprintln!("[script] blocked by CSP: inline script"); 241 + continue; 242 + } 231 243 (text.clone(), "<inline>".to_string()) 232 244 } else { 233 245 continue;