Skip to main content

rama_http_headers/common/content_security_policy/
source_expression.rs

1use std::borrow::Cow;
2use std::fmt::{self, Write as _};
3use std::str::FromStr;
4
5use rama_net::Protocol;
6use rama_net::address::Domain;
7
8use crate::Error;
9
10use super::host_source::HostSource;
11
12/// Cryptographic hash algorithm for [`SourceExpression::Hash`].
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum HashAlgorithm {
15    /// SHA-256.
16    Sha256,
17    /// SHA-384.
18    Sha384,
19    /// SHA-512.
20    Sha512,
21}
22
23impl HashAlgorithm {
24    /// Lowercase token used in CSP serialisation (`sha256` / `sha384`
25    /// / `sha512`).
26    pub const fn as_str(&self) -> &'static str {
27        match self {
28            Self::Sha256 => "sha256",
29            Self::Sha384 => "sha384",
30            Self::Sha512 => "sha512",
31        }
32    }
33}
34
35impl fmt::Display for HashAlgorithm {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(self.as_str())
38    }
39}
40
41/// One CSP *source expression* — the building block of a source-list.
42///
43/// Every variant has a canonical wire form defined by
44/// [CSP Level 3 § 2.3](https://www.w3.org/TR/CSP3/#framework-source-list);
45/// see [`fmt::Display`] for the mapping.
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub enum SourceExpression {
48    /// Same-origin sources only.
49    SelfOrigin,
50    /// No sources are permitted. Per spec must appear alone in a list.
51    None,
52    /// Permit inline `<script>` / `<style>` blocks and inline event
53    /// handlers — generally a bad idea on a hardened policy.
54    UnsafeInline,
55    /// Permit `eval` and related JS APIs.
56    UnsafeEval,
57    /// Trust scripts loaded by a nonced / hashed script, ignoring the
58    /// rest of the source list.
59    StrictDynamic,
60    /// Permit specific inline event handlers / style attributes via a
61    /// matching hash source.
62    UnsafeHashes,
63    /// Permit `WebAssembly.instantiate` from buffers (not just from
64    /// fetched script sources).
65    WasmUnsafeEval,
66    /// Include a sample of the violation in CSP reports.
67    ReportSample,
68    /// Permit inline `<script type="speculationrules">` blocks.
69    InlineSpeculationRules,
70    /// Match any source (`*`). Equivalent to "no restriction" — only
71    /// useful for testing.
72    Wildcard,
73    /// A whole scheme (e.g. `data:`, `blob:`, `mediastream:`). The
74    /// trailing `:` is emitted by the serialiser.
75    Scheme(Protocol),
76    /// A host source — scheme/host/port/path combination, see
77    /// [`HostSource`].
78    Host(HostSource),
79    /// `'nonce-<base64>'` — pairs with `nonce="…"` on the inline element.
80    Nonce(Cow<'static, str>),
81    /// `'<algo>-<base64>'` matching an inline script or style.
82    Hash {
83        algorithm: HashAlgorithm,
84        /// Base64-encoded digest, without the `<algo>-` prefix.
85        value: Cow<'static, str>,
86    },
87}
88
89impl SourceExpression {
90    /// Build a [`SourceExpression::Scheme`] from a [`Protocol`] (or
91    /// anything convertible into one).
92    pub fn scheme(scheme: impl Into<Protocol>) -> Self {
93        Self::Scheme(scheme.into())
94    }
95
96    /// Build a [`SourceExpression::Host`] from anything convertible
97    /// into a [`HostSource`] (e.g. a [`Domain`] or a host-source
98    /// string via [`HostSource::try_parse`]).
99    pub fn host(host: impl Into<HostSource>) -> Self {
100        Self::Host(host.into())
101    }
102
103    /// Build a [`SourceExpression::Nonce`].
104    pub fn nonce(nonce: impl Into<Cow<'static, str>>) -> Self {
105        Self::Nonce(nonce.into())
106    }
107
108    /// Build a [`SourceExpression::Hash`].
109    pub fn hash(algorithm: HashAlgorithm, value: impl Into<Cow<'static, str>>) -> Self {
110        Self::Hash {
111            algorithm,
112            value: value.into(),
113        }
114    }
115}
116
117impl fmt::Display for SourceExpression {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match self {
120            Self::SelfOrigin => f.write_str("'self'"),
121            Self::None => f.write_str("'none'"),
122            Self::UnsafeInline => f.write_str("'unsafe-inline'"),
123            Self::UnsafeEval => f.write_str("'unsafe-eval'"),
124            Self::StrictDynamic => f.write_str("'strict-dynamic'"),
125            Self::UnsafeHashes => f.write_str("'unsafe-hashes'"),
126            Self::WasmUnsafeEval => f.write_str("'wasm-unsafe-eval'"),
127            Self::ReportSample => f.write_str("'report-sample'"),
128            Self::InlineSpeculationRules => f.write_str("'inline-speculation-rules'"),
129            Self::Wildcard => f.write_char('*'),
130            Self::Scheme(p) => write!(f, "{}:", p.as_str()),
131            Self::Host(h) => write!(f, "{h}"),
132            Self::Nonce(n) => write!(f, "'nonce-{n}'"),
133            Self::Hash { algorithm, value } => write!(f, "'{algorithm}-{value}'"),
134        }
135    }
136}
137
138impl From<Protocol> for SourceExpression {
139    fn from(p: Protocol) -> Self {
140        Self::Scheme(p)
141    }
142}
143
144impl From<Domain> for SourceExpression {
145    fn from(d: Domain) -> Self {
146        Self::Host(HostSource::new(d))
147    }
148}
149
150impl From<HostSource> for SourceExpression {
151    fn from(h: HostSource) -> Self {
152        Self::Host(h)
153    }
154}
155
156impl FromStr for SourceExpression {
157    type Err = Error;
158
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        // The parser here is intentionally permissive: tokens that are
161        // not a recognised keyword / nonce / hash / scheme-only string
162        // fall through to a best-effort `HostSource` parse, which itself
163        // accepts the wildcard subdomain and `:*` forms.
164        match s {
165            "*" => return Ok(Self::Wildcard),
166            "'self'" => return Ok(Self::SelfOrigin),
167            "'none'" => return Ok(Self::None),
168            "'unsafe-inline'" => return Ok(Self::UnsafeInline),
169            "'unsafe-eval'" => return Ok(Self::UnsafeEval),
170            "'strict-dynamic'" => return Ok(Self::StrictDynamic),
171            "'unsafe-hashes'" => return Ok(Self::UnsafeHashes),
172            "'wasm-unsafe-eval'" => return Ok(Self::WasmUnsafeEval),
173            "'report-sample'" => return Ok(Self::ReportSample),
174            "'inline-speculation-rules'" => return Ok(Self::InlineSpeculationRules),
175            _ => {}
176        }
177
178        // `'nonce-<x>'` / `'<alg>-<x>'`.
179        if let Some(inner) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
180            if let Some(nonce) = inner.strip_prefix("nonce-") {
181                return Ok(Self::Nonce(Cow::Owned(nonce.to_owned())));
182            }
183            for alg in [
184                HashAlgorithm::Sha256,
185                HashAlgorithm::Sha384,
186                HashAlgorithm::Sha512,
187            ] {
188                if let Some(hash) = inner
189                    .strip_prefix(alg.as_str())
190                    .and_then(|value| value.strip_prefix('-'))
191                {
192                    return Ok(Self::Hash {
193                        algorithm: alg,
194                        value: Cow::Owned(hash.to_owned()),
195                    });
196                }
197            }
198            // Quoted keyword we don't recognise — keep it round-trip-safe
199            // by stashing the whole token as a host (it would be invalid
200            // CSP either way; preserving the bytes lets the caller log
201            // it sensibly).
202            return HostSource::try_parse(s)
203                .map(Self::Host)
204                .map_err(|_err| Error::invalid());
205        }
206
207        // Scheme-only source: `data:`, `https:`, ... (no `/`, ends in `:`).
208        if let Some(scheme) = s.strip_suffix(':')
209            && !scheme.is_empty()
210            && !s.contains('/')
211        {
212            let proto = Protocol::try_from(scheme).map_err(|_err| Error::invalid())?;
213            return Ok(Self::Scheme(proto));
214        }
215
216        // Otherwise — host source (may include scheme + port + path).
217        HostSource::try_parse(s).map(Self::Host)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn keywords_round_trip() {
227        for (s, want) in [
228            ("'self'", SourceExpression::SelfOrigin),
229            ("'none'", SourceExpression::None),
230            ("'unsafe-inline'", SourceExpression::UnsafeInline),
231            ("'unsafe-eval'", SourceExpression::UnsafeEval),
232            ("'strict-dynamic'", SourceExpression::StrictDynamic),
233            ("*", SourceExpression::Wildcard),
234        ] {
235            assert_eq!(SourceExpression::from_str(s).unwrap(), want);
236            assert_eq!(want.to_string(), s);
237        }
238    }
239
240    #[test]
241    fn scheme_uses_typed_protocol() {
242        let parsed = SourceExpression::from_str("data:").unwrap();
243        assert!(matches!(parsed, SourceExpression::Scheme(ref p) if p.as_str() == "data"));
244        assert_eq!(parsed.to_string(), "data:");
245
246        let https = SourceExpression::scheme(Protocol::HTTPS);
247        assert_eq!(https.to_string(), "https:");
248    }
249
250    #[test]
251    fn host_uses_typed_host_source() {
252        let parsed = SourceExpression::from_str("https://raw.githubusercontent.com").unwrap();
253        match parsed {
254            SourceExpression::Host(h) => {
255                assert_eq!(h.scheme().unwrap().as_str(), "https");
256                assert_eq!(h.host().as_ref(), "raw.githubusercontent.com");
257            }
258            other => panic!("expected Host, got {other:?}"),
259        }
260    }
261
262    #[test]
263    fn wildcard_subdomain_host_round_trips() {
264        let parsed = SourceExpression::from_str("*.example.com").unwrap();
265        assert_eq!(parsed.to_string(), "*.example.com");
266    }
267
268    #[test]
269    fn nonce_and_hash_round_trip() {
270        assert_eq!(
271            SourceExpression::from_str("'nonce-abc'")
272                .unwrap()
273                .to_string(),
274            "'nonce-abc'"
275        );
276        let h = SourceExpression::from_str("'sha384-xyz'").unwrap();
277        assert!(matches!(
278            h,
279            SourceExpression::Hash {
280                algorithm: HashAlgorithm::Sha384,
281                ..
282            }
283        ));
284        assert_eq!(h.to_string(), "'sha384-xyz'");
285    }
286
287    #[test]
288    fn from_impls_construct_typed_sources() {
289        let from_proto: SourceExpression = Protocol::HTTPS.into();
290        assert_eq!(from_proto.to_string(), "https:");
291
292        let from_domain: SourceExpression = Domain::from_static("example.com").into();
293        assert_eq!(from_domain.to_string(), "example.com");
294    }
295}