Skip to main content

sofie_http/
content_security.rs

1use http::HeaderValue;
2use sofie_core::{Error, Result};
3
4use crate::{App, Area, Serving};
5
6const BEFORE_NONCE: &str = "default-src 'none'; script-src 'self' 'nonce-";
7
8/// Hosts an area's pages may reach besides their own; nothing until told. Each source is
9/// `https://host[:port]`, its leftmost label `*` for any subdomain.
10#[derive(Clone, Debug, Default)]
11pub struct ContentSecurity {
12    sources: Vec<(Directive, String)>,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16enum Directive {
17    Script,
18    Style,
19    Image,
20    Font,
21    Connect,
22    Frame,
23    FormAction,
24}
25
26impl ContentSecurity {
27    #[must_use]
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    #[must_use]
33    pub fn script(self, source: &str) -> Self {
34        self.with(Directive::Script, source)
35    }
36
37    #[must_use]
38    pub fn style(self, source: &str) -> Self {
39        self.with(Directive::Style, source)
40    }
41
42    #[must_use]
43    pub fn image(self, source: &str) -> Self {
44        self.with(Directive::Image, source)
45    }
46
47    #[must_use]
48    pub fn font(self, source: &str) -> Self {
49        self.with(Directive::Font, source)
50    }
51
52    #[must_use]
53    pub fn connect(self, source: &str) -> Self {
54        self.with(Directive::Connect, source)
55    }
56
57    /// For frames the page embeds; who may frame the page stays `'none'`.
58    #[must_use]
59    pub fn frame(self, source: &str) -> Self {
60        self.with(Directive::Frame, source)
61    }
62
63    #[must_use]
64    pub fn form_action(self, source: &str) -> Self {
65        self.with(Directive::FormAction, source)
66    }
67
68    fn with(mut self, directive: Directive, source: &str) -> Self {
69        self.sources.push((directive, source.to_owned()));
70        self
71    }
72}
73
74impl Serving {
75    /// Replaces the area's earlier sources; those given to `content_security_all` add to them.
76    #[must_use]
77    pub fn content_security<A: Area>(mut self, sources: ContentSecurity) -> Self {
78        self.content_security.insert(A::NAME, sources);
79        self
80    }
81
82    #[must_use]
83    pub fn content_security_all(mut self, sources: ContentSecurity) -> Self {
84        self.content_security_everywhere = sources;
85        self
86    }
87}
88
89/// The policy after the nonce, built once at boot, so a page only joins the two.
90pub(crate) struct AreaContentSecurity {
91    after_nonce: String,
92}
93
94impl Default for AreaContentSecurity {
95    fn default() -> Self {
96        Self::of(&[])
97    }
98}
99
100impl AreaContentSecurity {
101    fn of(sources: &[(Directive, String)]) -> Self {
102        let listed = |directive: Directive| {
103            sources
104                .iter()
105                .filter(move |(named, _)| *named == directive)
106                .flat_map(|(_, source)| [" ", source.as_str()])
107                .collect::<String>()
108        };
109        let only_if_listed = |name: &str, directive: Directive| {
110            let listed = listed(directive);
111            if listed.is_empty() {
112                return String::new();
113            }
114            format!(" {name}{listed};")
115        };
116        let after_nonce = format!(
117            "'{}; style-src 'self'{}; img-src 'self' data:{}; font-src 'self'{}; \
118             connect-src 'self'{};{} form-action 'self'{}; frame-ancestors 'none'; \
119             base-uri 'none'",
120            listed(Directive::Script),
121            listed(Directive::Style),
122            listed(Directive::Image),
123            listed(Directive::Font),
124            listed(Directive::Connect),
125            only_if_listed("frame-src", Directive::Frame),
126            listed(Directive::FormAction),
127        );
128        Self { after_nonce }
129    }
130
131    pub(crate) fn header(&self, nonce: &str) -> Result<HeaderValue> {
132        HeaderValue::try_from(format!("{BEFORE_NONCE}{nonce}{}", self.after_nonce))
133            .map_err(|error| Error::internal("building the content security policy", error))
134    }
135}
136
137pub(crate) fn prepare(app: &mut App) -> Result<()> {
138    let everywhere = std::mem::take(&mut app.serving.content_security_everywhere);
139    let mut by_area = std::mem::take(&mut app.serving.content_security);
140    for route in &app.routes {
141        by_area.entry(route.audience.name).or_default();
142    }
143    for (area, own) in by_area {
144        let sources = everywhere
145            .sources
146            .iter()
147            .chain(&own.sources)
148            .map(|(directive, source)| Ok((*directive, exact_source(source)?)))
149            .collect::<Result<Vec<_>>>()
150            .map_err(|error| {
151                Error::internal(
152                    format!("the content security policy of area `{area}`"),
153                    error,
154                )
155            })?;
156        app.areas.entry(area).or_default().content_security = AreaContentSecurity::of(&sources);
157    }
158    Ok(())
159}
160
161/// Only HTTPS hosts: a keyword such as `'unsafe-inline'`, a scheme or `*` would undo the policy.
162fn exact_source(text: &str) -> Result<String> {
163    let invalid = || {
164        Error::internal_message(format!(
165            "`{text}` is not a source such as https://js.example.com or https://*.example.com"
166        ))
167    };
168    let authority = text.strip_prefix("https://").ok_or_else(invalid)?;
169    let host = authority.strip_prefix("*.").unwrap_or(authority);
170    let valid = !host.is_empty()
171        && host
172            .bytes()
173            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b':'))
174        && host.parse::<http::uri::Authority>().is_ok();
175    if !valid {
176        return Err(invalid());
177    }
178    Ok(text.to_ascii_lowercase())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn policy(sources: &[(Directive, &str)]) -> String {
186        let sources: Vec<_> = sources
187            .iter()
188            .map(|(directive, source)| (*directive, (*source).to_owned()))
189            .collect();
190        let header = AreaContentSecurity::of(&sources).header("abc").unwrap();
191        header.to_str().unwrap().to_owned()
192    }
193
194    #[test]
195    fn the_default_allows_only_the_pages_own_and_nonced_scripts() {
196        assert_eq!(
197            policy(&[]),
198            "default-src 'none'; script-src 'self' 'nonce-abc'; style-src 'self'; \
199             img-src 'self' data:; font-src 'self'; connect-src 'self'; \
200             form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
201        );
202    }
203
204    #[test]
205    fn sources_join_their_directives_and_frames_appear_only_when_listed() {
206        assert_eq!(
207            policy(&[
208                (Directive::Script, "https://js.provider.example"),
209                (Directive::Script, "https://*.js.provider.example"),
210                (Directive::Frame, "https://js.provider.example"),
211                (Directive::Connect, "https://api.provider.example"),
212                (Directive::Font, "https://fonts.example"),
213                (Directive::FormAction, "https://pay.provider.example"),
214            ]),
215            "default-src 'none'; script-src 'self' 'nonce-abc' https://js.provider.example \
216             https://*.js.provider.example; style-src 'self'; img-src 'self' data:; \
217             font-src 'self' https://fonts.example; connect-src 'self' https://api.provider.example; \
218             frame-src https://js.provider.example; form-action 'self' https://pay.provider.example; \
219             frame-ancestors 'none'; base-uri 'none'"
220        );
221    }
222
223    #[test]
224    fn a_source_is_an_https_host_or_its_subdomains() {
225        assert_eq!(
226            exact_source("https://JS.Provider.example").unwrap(),
227            "https://js.provider.example"
228        );
229        assert!(exact_source("https://*.provider.example:8443").is_ok());
230        for invalid in [
231            "*",
232            "https://*",
233            "https://",
234            "http://js.provider.example",
235            "js.provider.example",
236            "'unsafe-inline'",
237            "'unsafe-eval'",
238            "data:",
239            "https://js.provider.example/v3",
240            "https://a.*.provider.example",
241            "https://user@provider.example",
242            "https://js.provider.example; script-src *",
243            "https://js.provider.example 'unsafe-inline'",
244        ] {
245            assert!(exact_source(invalid).is_err(), "{invalid}");
246        }
247    }
248}