Skip to main content

rama_http_headers/common/content_security_policy/
mod.rs

1//! `Content-Security-Policy` header — [CSP Level 3].
2//!
3//! [CSP Level 3]: https://www.w3.org/TR/CSP3/
4//!
5//! A policy is built up from typed directives — each pairing a
6//! [`DirectiveName`] with a [`SourceList`] of [`SourceExpression`]s.
7//! Per-directive `with_*` / `set_*` setters (generated via
8//! [`rama_utils::macros::generate_set_and_with`]) cover what you'll
9//! usually need; the generic [`ContentSecurityPolicy::with`] /
10//! [`ContentSecurityPolicy::set`] escape hatch handles anything else.
11
12mod 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/// `Content-Security-Policy` response header.
31///
32/// Adding a directive that already exists in the policy *replaces* its
33/// source-list in place (preserving declared order). The user agent
34/// would ignore a second occurrence anyway, so we keep the value the
35/// caller most recently supplied.
36///
37/// # Examples
38///
39/// ```
40/// use rama_http_headers::{ContentSecurityPolicy, HostSource, SourceList};
41///
42/// let csp = ContentSecurityPolicy::strict_self().with_img_src(
43///     SourceList::self_origin()
44///         .with_data()
45///         .with_host(HostSource::try_parse("https://raw.githubusercontent.com").unwrap()),
46/// );
47///
48/// let rendered = csp.to_string();
49/// assert!(rendered.contains("img-src 'self' data: https://raw.githubusercontent.com"));
50/// assert!(rendered.contains("frame-ancestors 'none'"));
51/// ```
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct ContentSecurityPolicy {
54    directives: Vec<Directive>,
55}
56
57impl ContentSecurityPolicy {
58    /// Empty policy. Build from this when you want to add directives
59    /// one at a time rather than starting from a baseline.
60    #[must_use]
61    pub const fn empty() -> Self {
62        Self {
63            directives: Vec::new(),
64        }
65    }
66
67    /// Strict same-origin baseline:
68    ///
69    /// ```text
70    /// default-src 'self'; script-src 'self'; style-src 'self';
71    /// img-src 'self'; font-src 'self'; connect-src 'self';
72    /// form-action 'self'; base-uri 'self'; frame-ancestors 'none'
73    /// ```
74    #[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    /// Iterate the policy's directives in encoding order.
89    pub fn directives(&self) -> impl Iterator<Item = &Directive> + '_ {
90        self.directives.iter()
91    }
92
93    /// Generic escape hatch: append or replace any directive by name.
94    /// If the directive already exists, its source-list is overwritten
95    /// in place (order preserved); otherwise it's appended.
96    #[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    /// In-place sibling of [`with`](Self::with).
103    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    // ---- per-directive convenience setters ----
117    //
118    // Each macro invocation generates both a `with_*` (chaining, takes
119    // ownership) and a `set_*` (`&mut self`) form.
120
121    generate_set_and_with! {
122        /// Set `default-src`.
123        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        /// Set `script-src`.
130        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        /// Set `style-src`.
137        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        /// Set `img-src`.
144        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        /// Set `font-src`.
151        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        /// Set `connect-src`.
158        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        /// Set `media-src`.
165        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        /// Set `object-src`.
172        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        /// Set `frame-src`.
179        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        /// Set `frame-ancestors`. Note that nonces, hashes, and
186        /// `'unsafe-inline'` are not valid sources here per CSP3 § 6.1.2
187        /// (we don't enforce that — just be aware).
188        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        /// Set `child-src`.
195        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        /// Set `worker-src`.
202        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        /// Set `manifest-src`.
209        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        /// Set `form-action`.
216        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        /// Set `base-uri`.
223        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        /// Set `navigate-to`.
230        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        /// Set `report-to`.
237        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        /// Set the valueless `upgrade-insecure-requests` directive.
244        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        // CSP allows the header to be set multiple times — the user agent
272        // enforces the intersection of all returned policies. For
273        // round-tripping we concatenate them preserving order.
274        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                // Skip tokens we can't parse — these are rare on
290                // well-formed inputs (every wire keyword / scheme / host
291                // shape is accepted), and dropping the rest of the
292                // directive on one malformed token would be more
293                // surprising than logging it and moving on. Callers
294                // wanting strict parsing should pre-validate.
295                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                // All typed paths produce ASCII; degrade to empty rather
315                // than panic inside the response stack.
316                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}