The headers every response of a site carries, built in one place so a response cannot leave without them and a policy cannot name another origin by accident.
A [Policy] starts as "nothing, except this site itself". The only ways to widen it are named methods;
a source is never a wildcard, never https: as a whole, and a remote origin must be given as a whole
https://host through [Policy::allow_origin], which refuses anything else. [Policy::headers] returns
name and value pairs; an adapter puts them on a response.
9use std::fmt;
What a page may load.
An origin that is not a plain https://host (a wildcard, a scheme alone, a path or a quote).
Itself and nothing else: no script but its own files, no eval, no inline script, no data:
images, no other origin, no framing, no forms.
Datastar compiles each data-* expression with new Function, which needs 'unsafe-eval'.
Inline style attributes (an SVG's pivots, a bar's width) need 'unsafe-inline' for styles.
data: images, for fields a script draws into the page's background.
Inline <script> blocks, for a site whose pages carry their script in the page. A site that can
link its scripts should not.
A script, style, image, font or connection from one other origin, as https://host.
66 pub fn allow_origin(mut self, origin: &str) -> Result<Policy, NotAnOrigin> { 67 let host = origin.strip_prefix("https://").unwrap_or(""); 68 let plain = !host.is_empty() 69 && host.bytes().all(|b| b.is_ascii_alphanumeric() || b".-:".contains(&b)) 70 && !host.starts_with('.') 71 && host.contains('.'); 72 if !plain { 73 return Err(NotAnOrigin(origin.to_owned())); 74 } 75 if !self.origins.iter().any(|known| known == origin) { 76 self.origins.push(origin.to_owned()); 77 } 78 Ok(self) 79 }
The content-security-policy value.
82 pub fn csp(&self) -> String { 83 let others = self.origins.iter().fold(String::new(), |all, origin| format!("{all} {origin}")); 84 let script = format!( 85 "'self'{}{}{others}", 86 if self.unsafe_eval { " 'unsafe-eval'" } else { "" }, 87 if self.inline_script { " 'unsafe-inline'" } else { "" } 88 ); 89 let style = format!("'self'{}{others}", if self.style_unsafe_inline { " 'unsafe-inline'" } else { "" }); 90 let image = format!("'self'{}{others}", if self.data_images { " data:" } else { "" }); 91 format!( 92 "default-src 'none'; script-src {script}; style-src {style}; img-src {image}; font-src 'self'{others}; \ 93 media-src 'self'; connect-src 'self'{others}; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" 94 ) 95 }
Every header a response carries, name and value.
98 pub fn headers(&self) -> Vec<(&'static str, String)> { 99 vec![ 100 ("content-security-policy", self.csp()), 101 ("x-content-type-options", "nosniff".to_owned()), 102 ("referrer-policy", "no-referrer".to_owned()), 103 ("permissions-policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()".to_owned()), 104 ("cross-origin-opener-policy", "same-origin".to_owned()), 105 ] 106 } 107}
The cache-control of an HTML page. Cloudflare adds its Web Analytics script to HTML as it passes
through unless the response says no-transform, so every page says it: this is the only way to write
an HTML cache header here.
116#[cfg(test)] 117mod tests { 118 use super::*; 119 120 fn sources(csp: &str) -> Vec<(String, Vec<String>)> { 121 csp.split(';') 122 .map(|d| { 123 let mut words = d.split_whitespace().map(str::to_owned); 124 (words.next().unwrap_or_default(), words.collect()) 125 }) 126 .collect() 127 } 128 129 #[test] 130 fn the_default_loads_nothing_from_elsewhere() { 131 let csp = Policy::own_origin_only().csp(); 132 assert!(csp.starts_with("default-src 'none'")); 133 for (_, list) in sources(&csp) { 134 for source in list { 135 assert!(!source.contains("://") && source != "*" && source != "https:", "{csp}"); 136 } 137 } 138 assert!(!csp.contains("unsafe-eval") && !csp.contains("unsafe-inline") && !csp.contains("data:")); 139 } 140 141 #[test] 142 fn whiskers_policy_is_what_it_was_by_hand() { 143 let csp = Policy::own_origin_only().for_datastar().with_inline_styles().with_data_images().csp(); 144 assert_eq!( 145 csp, 146 "default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; \ 147 media-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" 148 ); 149 } 150 151 #[test] 152 fn widening_is_by_name_and_inline_script_stays_off_by_default() { 153 let csp = Policy::own_origin_only().for_datastar().csp(); 154 assert!(!csp.contains("script-src 'self' 'unsafe-inline'")); 155 assert!(Policy::own_origin_only().with_inline_script().csp().contains("script-src 'self' 'unsafe-inline'")); 156 } 157 158 #[test] 159 fn an_origin_is_a_whole_https_host_or_it_is_refused() { 160 for bad in ["*", "https:", "https://*.example.com", "http://example.com", "https://example.com/x", "https://", "example.com", "https://a b.com", "https://localhost", "https://.com"] { 161 assert_eq!(Policy::own_origin_only().allow_origin(bad), Err(NotAnOrigin(bad.to_owned())), "{bad}"); 162 } 163 let csp = Policy::own_origin_only().allow_origin("https://cdn.jsdelivr.net").unwrap().allow_origin("https://cdn.jsdelivr.net").unwrap().csp(); 164 assert_eq!(csp.matches("https://cdn.jsdelivr.net").count(), 5, "script, style, image, font and connect once each, and media not at all: {csp}"); 165 } 166 167 #[test] 168 fn every_response_carries_five_headers() { 169 let headers = Policy::own_origin_only().headers(); 170 assert_eq!(headers.len(), 5); 171 assert!(headers.contains(&("x-content-type-options", "nosniff".to_owned()))); 172 assert!(headers.iter().all(|(name, _)| name.chars().all(|c| c.is_ascii_lowercase() || c == '-'))); 173 } 174 175 #[test] 176 fn html_asks_cloudflare_not_to_add_its_analytics() { 177 assert_eq!(html_cache_control(300), "public, max-age=300, no-transform"); 178 } 179}