rama_http_headers/common/content_security_policy/
mod.rs1mod directive;
13mod host_source;
14mod source_expression;
15mod source_list;
16
17pub use self::directive::{Directive, DirectiveName};
18pub use self::host_source::{HostSource, HostSourcePort};
19pub use self::source_expression::{HashAlgorithm, SourceExpression};
20pub use self::source_list::SourceList;
21
22use std::fmt;
23use std::str::FromStr;
24
25use rama_http_types::{HeaderName, HeaderValue};
26use rama_utils::macros::generate_set_and_with;
27
28use crate::{Error, HeaderDecode, HeaderEncode, TypedHeader};
29
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct ContentSecurityPolicy {
54 directives: Vec<Directive>,
55}
56
57impl ContentSecurityPolicy {
58 #[must_use]
61 pub const fn empty() -> Self {
62 Self {
63 directives: Vec::new(),
64 }
65 }
66
67 #[must_use]
75 pub fn strict_self() -> Self {
76 Self::empty()
77 .with_default_src(SourceList::self_origin())
78 .with_script_src(SourceList::self_origin())
79 .with_style_src(SourceList::self_origin())
80 .with_img_src(SourceList::self_origin())
81 .with_font_src(SourceList::self_origin())
82 .with_connect_src(SourceList::self_origin())
83 .with_form_action(SourceList::self_origin())
84 .with_base_uri(SourceList::self_origin())
85 .with_frame_ancestors(SourceList::none())
86 }
87
88 pub fn directives(&self) -> impl Iterator<Item = &Directive> + '_ {
90 self.directives.iter()
91 }
92
93 #[must_use]
97 pub fn with(mut self, name: impl Into<DirectiveName>, sources: SourceList) -> Self {
98 self.upsert(name.into(), sources);
99 self
100 }
101
102 pub fn set(&mut self, name: impl Into<DirectiveName>, sources: SourceList) -> &mut Self {
104 self.upsert(name.into(), sources);
105 self
106 }
107
108 fn upsert(&mut self, name: DirectiveName, sources: SourceList) {
109 if let Some(slot) = self.directives.iter_mut().find(|d| d.name == name) {
110 slot.sources = sources;
111 } else {
112 self.directives.push(Directive { name, sources });
113 }
114 }
115
116 generate_set_and_with! {
122 pub fn default_src(mut self, sources: SourceList) -> Self {
124 self.upsert(DirectiveName::DefaultSrc, sources);
125 self
126 }
127 }
128 generate_set_and_with! {
129 pub fn script_src(mut self, sources: SourceList) -> Self {
131 self.upsert(DirectiveName::ScriptSrc, sources);
132 self
133 }
134 }
135 generate_set_and_with! {
136 pub fn style_src(mut self, sources: SourceList) -> Self {
138 self.upsert(DirectiveName::StyleSrc, sources);
139 self
140 }
141 }
142 generate_set_and_with! {
143 pub fn img_src(mut self, sources: SourceList) -> Self {
145 self.upsert(DirectiveName::ImgSrc, sources);
146 self
147 }
148 }
149 generate_set_and_with! {
150 pub fn font_src(mut self, sources: SourceList) -> Self {
152 self.upsert(DirectiveName::FontSrc, sources);
153 self
154 }
155 }
156 generate_set_and_with! {
157 pub fn connect_src(mut self, sources: SourceList) -> Self {
159 self.upsert(DirectiveName::ConnectSrc, sources);
160 self
161 }
162 }
163 generate_set_and_with! {
164 pub fn media_src(mut self, sources: SourceList) -> Self {
166 self.upsert(DirectiveName::MediaSrc, sources);
167 self
168 }
169 }
170 generate_set_and_with! {
171 pub fn object_src(mut self, sources: SourceList) -> Self {
173 self.upsert(DirectiveName::ObjectSrc, sources);
174 self
175 }
176 }
177 generate_set_and_with! {
178 pub fn frame_src(mut self, sources: SourceList) -> Self {
180 self.upsert(DirectiveName::FrameSrc, sources);
181 self
182 }
183 }
184 generate_set_and_with! {
185 pub fn frame_ancestors(mut self, sources: SourceList) -> Self {
189 self.upsert(DirectiveName::FrameAncestors, sources);
190 self
191 }
192 }
193 generate_set_and_with! {
194 pub fn child_src(mut self, sources: SourceList) -> Self {
196 self.upsert(DirectiveName::ChildSrc, sources);
197 self
198 }
199 }
200 generate_set_and_with! {
201 pub fn worker_src(mut self, sources: SourceList) -> Self {
203 self.upsert(DirectiveName::WorkerSrc, sources);
204 self
205 }
206 }
207 generate_set_and_with! {
208 pub fn manifest_src(mut self, sources: SourceList) -> Self {
210 self.upsert(DirectiveName::ManifestSrc, sources);
211 self
212 }
213 }
214 generate_set_and_with! {
215 pub fn form_action(mut self, sources: SourceList) -> Self {
217 self.upsert(DirectiveName::FormAction, sources);
218 self
219 }
220 }
221 generate_set_and_with! {
222 pub fn base_uri(mut self, sources: SourceList) -> Self {
224 self.upsert(DirectiveName::BaseUri, sources);
225 self
226 }
227 }
228 generate_set_and_with! {
229 pub fn navigate_to(mut self, sources: SourceList) -> Self {
231 self.upsert(DirectiveName::NavigateTo, sources);
232 self
233 }
234 }
235 generate_set_and_with! {
236 pub fn report_to(mut self, sources: SourceList) -> Self {
238 self.upsert(DirectiveName::ReportTo, sources);
239 self
240 }
241 }
242 generate_set_and_with! {
243 pub fn upgrade_insecure_requests(mut self) -> Self {
245 self.upsert(DirectiveName::UpgradeInsecureRequests, SourceList::empty());
246 self
247 }
248 }
249}
250
251impl fmt::Display for ContentSecurityPolicy {
252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253 for (i, d) in self.directives.iter().enumerate() {
254 if i > 0 {
255 f.write_str("; ")?;
256 }
257 d.fmt(f)?;
258 }
259 Ok(())
260 }
261}
262
263impl TypedHeader for ContentSecurityPolicy {
264 fn name() -> &'static HeaderName {
265 &::rama_http_types::header::CONTENT_SECURITY_POLICY
266 }
267}
268
269impl HeaderDecode for ContentSecurityPolicy {
270 fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
271 let mut out = Self::empty();
275 let mut any = false;
276 for value in values {
277 any = true;
278 let s = value.to_str().map_err(|_err| Error::invalid())?;
279 for raw in s.split(';') {
280 let trimmed = raw.trim();
281 if trimmed.is_empty() {
282 continue;
283 }
284 let mut parts = trimmed.split_whitespace();
285 let name = match parts.next() {
286 Some(n) => DirectiveName::from(n),
287 None => continue,
288 };
289 let sources: SourceList = parts
296 .filter_map(|tok| SourceExpression::from_str(tok).ok())
297 .collect();
298 out.directives.push(Directive { name, sources });
299 }
300 }
301 if !any {
302 return Err(Error::invalid());
303 }
304 Ok(out)
305 }
306}
307
308impl HeaderEncode for ContentSecurityPolicy {
309 fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
310 let rendered = self.to_string();
311 match HeaderValue::try_from(rendered) {
312 Ok(v) => values.extend(::std::iter::once(v)),
313 Err(_) => {
314 values.extend(::std::iter::once(HeaderValue::from_static("")));
317 }
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::super::{test_decode, test_encode};
325 use super::*;
326
327 use rama_net::Protocol;
328 use rama_net::address::Domain;
329
330 #[test]
331 fn typed_setters_replace_existing_directive_in_place() {
332 let csp = ContentSecurityPolicy::empty()
333 .with_default_src(SourceList::self_origin())
334 .with_img_src(SourceList::self_origin())
335 .with_style_src(SourceList::self_origin())
336 .with_img_src(SourceList::self_origin().with_data());
337 let names: Vec<&str> = csp.directives().map(|d| d.name.as_str()).collect();
338 assert_eq!(names, vec!["default-src", "img-src", "style-src"]);
339
340 let img = csp
341 .directives()
342 .find(|d| d.name == DirectiveName::ImgSrc)
343 .unwrap();
344 assert_eq!(img.sources.to_string(), "'self' data:");
345 }
346
347 #[test]
348 fn strict_self_renders_canonical_lockdown() {
349 let s = ContentSecurityPolicy::strict_self().to_string();
350 for expected in [
351 "default-src 'self'",
352 "script-src 'self'",
353 "style-src 'self'",
354 "img-src 'self'",
355 "font-src 'self'",
356 "connect-src 'self'",
357 "form-action 'self'",
358 "base-uri 'self'",
359 "frame-ancestors 'none'",
360 ] {
361 assert!(s.contains(expected), "missing `{expected}` in {s}");
362 }
363 }
364
365 #[test]
366 fn upgrade_insecure_requests_renders_as_valueless_directive() {
367 let csp = ContentSecurityPolicy::empty().with_upgrade_insecure_requests();
368 assert_eq!(csp.to_string(), "upgrade-insecure-requests");
369 }
370
371 #[test]
372 fn generic_with_escape_hatch_accepts_string_name() {
373 let csp =
374 ContentSecurityPolicy::empty().with("experimental-thing", SourceList::self_origin());
375 assert_eq!(csp.to_string(), "experimental-thing 'self'");
376 let d = csp.directives().next().unwrap();
377 assert!(
378 matches!(d.name, DirectiveName::Unknown(ref s) if s.as_ref() == "experimental-thing")
379 );
380 }
381
382 #[test]
383 fn set_mutates_in_place_via_generic_hatch() {
384 let mut csp = ContentSecurityPolicy::strict_self();
385 csp.set(DirectiveName::ConnectSrc, SourceList::none());
386 assert!(csp.to_string().contains("connect-src 'none'"));
387 }
388
389 #[test]
390 fn typed_host_source_in_img_src() {
391 let csp = ContentSecurityPolicy::empty().with_img_src(
392 SourceList::self_origin().with_data().with_host(
393 HostSource::new(Domain::from_static("raw.githubusercontent.com"))
394 .with_scheme(Protocol::HTTPS),
395 ),
396 );
397 assert_eq!(
398 csp.to_string(),
399 "img-src 'self' data: https://raw.githubusercontent.com"
400 );
401 }
402
403 #[test]
404 fn decode_round_trip_single_value_typed() {
405 let parsed = test_decode::<ContentSecurityPolicy>(&[
406 "default-src 'self'; script-src 'self' 'unsafe-inline'",
407 ])
408 .expect("should decode");
409 let names: Vec<&str> = parsed.directives().map(|d| d.name.as_str()).collect();
410 assert_eq!(names, vec!["default-src", "script-src"]);
411 let script = parsed
412 .directives()
413 .find(|d| d.name == DirectiveName::ScriptSrc)
414 .unwrap();
415 assert_eq!(
416 script.sources.as_slice(),
417 &[SourceExpression::SelfOrigin, SourceExpression::UnsafeInline,],
418 );
419 }
420
421 #[test]
422 fn decode_handles_multiple_header_values() {
423 let parsed =
424 test_decode::<ContentSecurityPolicy>(&["default-src 'self'", "img-src 'self' data:"])
425 .expect("should decode");
426 let names: Vec<&str> = parsed.directives().map(|d| d.name.as_str()).collect();
427 assert_eq!(names, vec!["default-src", "img-src"]);
428 }
429
430 #[test]
431 fn decode_tolerates_empty_segments_and_extra_whitespace() {
432 let parsed = test_decode::<ContentSecurityPolicy>(&[
433 " default-src 'self' ;; script-src 'self' ",
434 ])
435 .expect("should decode");
436 let names: Vec<&str> = parsed.directives().map(|d| d.name.as_str()).collect();
437 assert_eq!(names, vec!["default-src", "script-src"]);
438 }
439
440 #[test]
441 fn decode_directive_without_value() {
442 let parsed =
443 test_decode::<ContentSecurityPolicy>(&["upgrade-insecure-requests"]).expect("decode");
444 let d = parsed.directives().next().unwrap();
445 assert_eq!(d.name, DirectiveName::UpgradeInsecureRequests);
446 assert!(d.sources.as_slice().is_empty());
447 }
448
449 #[test]
450 fn decode_empty_returns_error() {
451 assert_eq!(test_decode::<ContentSecurityPolicy>(&[] as &[&str]), None);
452 }
453
454 #[test]
455 fn encode_round_trips_through_header_map() {
456 let csp = ContentSecurityPolicy::strict_self();
457 let map = test_encode(csp.clone());
458 let raw = map
459 .get(ContentSecurityPolicy::name())
460 .expect("header set")
461 .to_str()
462 .unwrap();
463 assert_eq!(raw, csp.to_string());
464 }
465
466 #[test]
467 fn full_decode_encode_round_trip() {
468 let original = ContentSecurityPolicy::strict_self()
469 .with_img_src(
470 SourceList::self_origin()
471 .with_data()
472 .with_host(HostSource::try_parse("https://raw.githubusercontent.com").unwrap()),
473 )
474 .with_connect_src(SourceList::self_origin())
475 .with_upgrade_insecure_requests();
476 let wire = original.to_string();
477 let parsed = test_decode::<ContentSecurityPolicy>(&[wire.as_str()]).expect("decode");
478 assert_eq!(parsed.to_string(), wire);
479 }
480}