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#[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 #[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 #[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
89pub(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
161fn 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}