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.strip_prefix(&format!("{}-", alg.as_str())) {
189                    return Ok(Self::Hash {
190                        algorithm: alg,
191                        value: Cow::Owned(hash.to_owned()),
192                    });
193                }
194            }
195            // Quoted keyword we don't recognise — keep it round-trip-safe
196            // by stashing the whole token as a host (it would be invalid
197            // CSP either way; preserving the bytes lets the caller log
198            // it sensibly).
199            return HostSource::try_parse(s)
200                .map(Self::Host)
201                .map_err(|_err| Error::invalid());
202        }
203
204        // Scheme-only source: `data:`, `https:`, ... (no `/`, ends in `:`).
205        if let Some(scheme) = s.strip_suffix(':')
206            && !scheme.is_empty()
207            && !s.contains('/')
208        {
209            let proto = Protocol::try_from(scheme).map_err(|_err| Error::invalid())?;
210            return Ok(Self::Scheme(proto));
211        }
212
213        // Otherwise — host source (may include scheme + port + path).
214        HostSource::try_parse(s).map(Self::Host)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn keywords_round_trip() {
224        for (s, want) in [
225            ("'self'", SourceExpression::SelfOrigin),
226            ("'none'", SourceExpression::None),
227            ("'unsafe-inline'", SourceExpression::UnsafeInline),
228            ("'unsafe-eval'", SourceExpression::UnsafeEval),
229            ("'strict-dynamic'", SourceExpression::StrictDynamic),
230            ("*", SourceExpression::Wildcard),
231        ] {
232            assert_eq!(SourceExpression::from_str(s).unwrap(), want);
233            assert_eq!(want.to_string(), s);
234        }
235    }
236
237    #[test]
238    fn scheme_uses_typed_protocol() {
239        let parsed = SourceExpression::from_str("data:").unwrap();
240        assert!(matches!(parsed, SourceExpression::Scheme(ref p) if p.as_str() == "data"));
241        assert_eq!(parsed.to_string(), "data:");
242
243        let https = SourceExpression::scheme(Protocol::HTTPS);
244        assert_eq!(https.to_string(), "https:");
245    }
246
247    #[test]
248    fn host_uses_typed_host_source() {
249        let parsed = SourceExpression::from_str("https://raw.githubusercontent.com").unwrap();
250        match parsed {
251            SourceExpression::Host(h) => {
252                assert_eq!(h.scheme().unwrap().as_str(), "https");
253                assert_eq!(h.host().as_ref(), "raw.githubusercontent.com");
254            }
255            other => panic!("expected Host, got {other:?}"),
256        }
257    }
258
259    #[test]
260    fn wildcard_subdomain_host_round_trips() {
261        let parsed = SourceExpression::from_str("*.example.com").unwrap();
262        assert_eq!(parsed.to_string(), "*.example.com");
263    }
264
265    #[test]
266    fn nonce_and_hash_round_trip() {
267        assert_eq!(
268            SourceExpression::from_str("'nonce-abc'")
269                .unwrap()
270                .to_string(),
271            "'nonce-abc'"
272        );
273        let h = SourceExpression::from_str("'sha384-xyz'").unwrap();
274        assert!(matches!(
275            h,
276            SourceExpression::Hash {
277                algorithm: HashAlgorithm::Sha384,
278                ..
279            }
280        ));
281        assert_eq!(h.to_string(), "'sha384-xyz'");
282    }
283
284    #[test]
285    fn from_impls_construct_typed_sources() {
286        let from_proto: SourceExpression = Protocol::HTTPS.into();
287        assert_eq!(from_proto.to_string(), "https:");
288
289        let from_domain: SourceExpression = Domain::from_static("example.com").into();
290        assert_eq!(from_domain.to_string(), "example.com");
291    }
292}