lib.rsannotatedlib.rssource179 lines · 7.5 KB · raw

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.

12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Policy {
14    unsafe_eval: bool,
15    style_unsafe_inline: bool,
16    inline_script: bool,
17    data_images: bool,
18    origins: Vec<String>,
19}

An origin that is not a plain https://host (a wildcard, a scheme alone, a path or a quote).

22#[derive(Debug, PartialEq, Eq)]
23pub struct NotAnOrigin(pub String);
25impl fmt::Display for NotAnOrigin {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(f, "{:?} is not an https origin (https://host, no wildcard, no path)", self.0)
28    }
29}
30
31impl std::error::Error for NotAnOrigin {}
32
33impl Policy {

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.

36    pub fn own_origin_only() -> Policy {
37        Policy { unsafe_eval: false, style_unsafe_inline: false, inline_script: false, data_images: false, origins: Vec::new() }
38    }

Datastar compiles each data-* expression with new Function, which needs 'unsafe-eval'.

41    pub fn for_datastar(mut self) -> Policy {
42        self.unsafe_eval = true;
43        self
44    }

Inline style attributes (an SVG's pivots, a bar's width) need 'unsafe-inline' for styles.

47    pub fn with_inline_styles(mut self) -> Policy {
48        self.style_unsafe_inline = true;
49        self
50    }

data: images, for fields a script draws into the page's background.

53    pub fn with_data_images(mut self) -> Policy {
54        self.data_images = true;
55        self
56    }

Inline <script> blocks, for a site whose pages carry their script in the page. A site that can link its scripts should not.

60    pub fn with_inline_script(mut self) -> Policy {
61        self.inline_script = true;
62        self
63    }

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.

112pub fn html_cache_control(max_age_seconds: u32) -> String {
113    format!("public, max-age={max_age_seconds}, no-transform")
114}
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}