Skip to main content

rama_http_headers/common/
cross_origin_opener_policy.rs

1//! `Cross-Origin-Opener-Policy` (COOP) and its report-only sibling.
2//!
3//! Per the [HTML Standard § the-coop-headers](https://html.spec.whatwg.org/multipage/browsers.html#the-coop-headers).
4
5use std::borrow::Cow;
6use std::fmt;
7
8use rama_http_types::{HeaderName, HeaderValue};
9use rama_utils::macros::enums::enum_builder;
10
11use crate::util::{self, IterExt};
12use crate::{Error, HeaderDecode, HeaderEncode, TypedHeader};
13
14use super::cross_origin_policy_util::{
15    SingleTokenWithReportTo, format_single_token_with_report_to, parse_single_token_with_report_to,
16};
17
18enum_builder! {
19    /// Opener-policy token values per the HTML Standard.
20    ///
21    /// Per [HTML § the-coop-headers](https://html.spec.whatwg.org/multipage/browsers.html#the-coop-headers),
22    /// these are the four token values a `Cross-Origin-Opener-Policy`
23    /// header may carry. The spec explicitly excludes
24    /// `same-origin-plus-COEP` from the valid wire values — that token
25    /// is the *computed* opener policy assigned when a `same-origin`
26    /// COOP is combined with a cross-origin-isolating COEP, and the
27    /// COOP header parser rejects it. It's intentionally not modelled
28    /// here.
29    ///
30    /// The auto-generated [`Unknown`](Self::Unknown) variant is
31    /// reachable only via direct construction; the COOP decoder uses
32    /// strict parsing and rejects any token outside the spec set.
33    @String
34    pub enum CrossOriginOpenerPolicyValue {
35        /// `unsafe-none` — the spec default when the header is absent.
36        UnsafeNone => "unsafe-none",
37        /// `same-origin-allow-popups` — same-origin opener
38        /// relationships are preserved, popups remain attached.
39        SameOriginAllowPopups => "same-origin-allow-popups",
40        /// `same-origin` — strict isolation; cross-origin openers are
41        /// severed from this window.
42        SameOrigin => "same-origin",
43        /// `noopener-allow-popups` — added 2024, severs the opener
44        /// while still letting popups open.
45        NoopenerAllowPopups => "noopener-allow-popups",
46    }
47}
48
49/// `Cross-Origin-Opener-Policy` (COOP) header.
50///
51/// Send `same-origin` (paired with a cross-origin-isolating
52/// `Cross-Origin-Embedder-Policy`) to opt the document into process
53/// isolation. Required for `SharedArrayBuffer` and other cross-origin-
54/// isolated capabilities. The browser then internally tracks the
55/// combined policy as `same-origin-plus-COEP`, which is *not* itself a
56/// header-settable value.
57///
58/// The optional `report-to` parameter names a [Reporting API endpoint]
59/// (defined in the `Reporting-Endpoints` header) where the browser
60/// posts violation reports.
61///
62/// [Reporting API endpoint]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Reporting-Endpoints
63///
64/// # Default semantics
65///
66/// When the header is absent the user agent applies `unsafe-none`. The
67/// [`UnsafeNone`](CrossOriginOpenerPolicyValue::UnsafeNone) variant
68/// represents the header being *present* with that explicit value —
69/// both produce the same browser behaviour but the presence is
70/// distinguishable on the wire.
71///
72/// # Example
73///
74/// ```
75/// use rama_http_headers::{CrossOriginOpenerPolicy, CrossOriginOpenerPolicyValue};
76///
77/// let coop = CrossOriginOpenerPolicy {
78///     value: CrossOriginOpenerPolicyValue::SameOrigin,
79///     report_to: Some("coop-endpoint".into()),
80/// };
81/// assert_eq!(coop.to_string(), r#"same-origin; report-to="coop-endpoint""#);
82/// ```
83#[derive(Clone, Debug, PartialEq, Eq, Hash)]
84pub struct CrossOriginOpenerPolicy {
85    pub value: CrossOriginOpenerPolicyValue,
86    /// `; report-to="<endpoint>"` — endpoint name defined in the
87    /// `Reporting-Endpoints` header. Always emitted as a quoted
88    /// sf-string per RFC 8941.
89    pub report_to: Option<Cow<'static, str>>,
90}
91
92impl CrossOriginOpenerPolicy {
93    /// Builder shortcut: `unsafe-none`, no `report-to`.
94    #[must_use]
95    pub fn unsafe_none() -> Self {
96        Self {
97            value: CrossOriginOpenerPolicyValue::UnsafeNone,
98            report_to: None,
99        }
100    }
101
102    /// Builder shortcut: `same-origin-allow-popups`, no `report-to`.
103    #[must_use]
104    pub fn same_origin_allow_popups() -> Self {
105        Self {
106            value: CrossOriginOpenerPolicyValue::SameOriginAllowPopups,
107            report_to: None,
108        }
109    }
110
111    /// Builder shortcut: `same-origin`, no `report-to`.
112    #[must_use]
113    pub fn same_origin() -> Self {
114        Self {
115            value: CrossOriginOpenerPolicyValue::SameOrigin,
116            report_to: None,
117        }
118    }
119
120    /// Builder shortcut: `noopener-allow-popups`, no `report-to`.
121    #[must_use]
122    pub fn noopener_allow_popups() -> Self {
123        Self {
124            value: CrossOriginOpenerPolicyValue::NoopenerAllowPopups,
125            report_to: None,
126        }
127    }
128
129    rama_utils::macros::generate_set_and_with! {
130        pub fn report_to(mut self, endpoint: impl Into<Cow<'static, str>>) -> Self {
131            self.report_to = Some(endpoint.into());
132            self
133        }
134    }
135}
136
137impl fmt::Display for CrossOriginOpenerPolicy {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        format_single_token_with_report_to(f, self.value.as_str(), self.report_to.as_deref())
140    }
141}
142
143impl TypedHeader for CrossOriginOpenerPolicy {
144    fn name() -> &'static HeaderName {
145        &::rama_http_types::header::CROSS_ORIGIN_OPENER_POLICY
146    }
147}
148
149impl HeaderDecode for CrossOriginOpenerPolicy {
150    fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
151        let raw = values
152            .just_one()
153            .and_then(|v| v.to_str().ok())
154            .ok_or_else(Error::invalid)?;
155        let SingleTokenWithReportTo { token, report_to } =
156            parse_single_token_with_report_to(raw).ok_or_else(Error::invalid)?;
157        let value = CrossOriginOpenerPolicyValue::strict_parse(token).ok_or_else(Error::invalid)?;
158        Ok(Self { value, report_to })
159    }
160}
161
162impl HeaderEncode for CrossOriginOpenerPolicy {
163    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
164        values.extend(::std::iter::once(util::fmt(self)));
165    }
166}
167
168/// `Cross-Origin-Opener-Policy-Report-Only` — same payload as
169/// [`CrossOriginOpenerPolicy`], reports violations without enforcing
170/// the policy.
171#[derive(Clone, Debug, PartialEq, Eq, Hash)]
172pub struct CrossOriginOpenerPolicyReportOnly {
173    pub value: CrossOriginOpenerPolicyValue,
174    pub report_to: Option<Cow<'static, str>>,
175}
176
177impl CrossOriginOpenerPolicyReportOnly {
178    /// Convert an enforcing [`CrossOriginOpenerPolicy`] to its
179    /// report-only sibling, mirroring the payload.
180    #[must_use]
181    pub fn from_enforcing(p: CrossOriginOpenerPolicy) -> Self {
182        Self {
183            value: p.value,
184            report_to: p.report_to,
185        }
186    }
187
188    rama_utils::macros::generate_set_and_with! {
189        pub fn report_to(mut self, endpoint: impl Into<Cow<'static, str>>) -> Self {
190            self.report_to = Some(endpoint.into());
191            self
192        }
193    }
194}
195
196impl fmt::Display for CrossOriginOpenerPolicyReportOnly {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        format_single_token_with_report_to(f, self.value.as_str(), self.report_to.as_deref())
199    }
200}
201
202impl TypedHeader for CrossOriginOpenerPolicyReportOnly {
203    fn name() -> &'static HeaderName {
204        &::rama_http_types::header::CROSS_ORIGIN_OPENER_POLICY_REPORT_ONLY
205    }
206}
207
208impl HeaderDecode for CrossOriginOpenerPolicyReportOnly {
209    fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
210        let raw = values
211            .just_one()
212            .and_then(|v| v.to_str().ok())
213            .ok_or_else(Error::invalid)?;
214        let SingleTokenWithReportTo { token, report_to } =
215            parse_single_token_with_report_to(raw).ok_or_else(Error::invalid)?;
216        let value = CrossOriginOpenerPolicyValue::strict_parse(token).ok_or_else(Error::invalid)?;
217        Ok(Self { value, report_to })
218    }
219}
220
221impl HeaderEncode for CrossOriginOpenerPolicyReportOnly {
222    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
223        values.extend(::std::iter::once(util::fmt(self)));
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::super::{test_decode, test_encode};
230    use super::*;
231
232    #[test]
233    fn round_trip_each_variant_no_report_to() {
234        for variant in [
235            CrossOriginOpenerPolicyValue::UnsafeNone,
236            CrossOriginOpenerPolicyValue::SameOriginAllowPopups,
237            CrossOriginOpenerPolicyValue::SameOrigin,
238            CrossOriginOpenerPolicyValue::NoopenerAllowPopups,
239        ] {
240            let expected_str = variant.as_str().to_owned();
241            let v = CrossOriginOpenerPolicy {
242                value: variant,
243                report_to: None,
244            };
245            let map = test_encode(v.clone());
246            let raw = map
247                .get(CrossOriginOpenerPolicy::name())
248                .expect("set")
249                .to_str()
250                .unwrap()
251                .to_owned();
252            assert_eq!(raw, expected_str);
253            let parsed = test_decode::<CrossOriginOpenerPolicy>(&[raw.as_str()]).expect("decode");
254            assert_eq!(parsed, v);
255        }
256    }
257
258    #[test]
259    fn round_trip_with_report_to() {
260        let v = CrossOriginOpenerPolicy::same_origin().with_report_to("coop-endpoint");
261        let map = test_encode(v.clone());
262        let raw = map
263            .get(CrossOriginOpenerPolicy::name())
264            .expect("set")
265            .to_str()
266            .unwrap()
267            .to_owned();
268        assert_eq!(raw, r#"same-origin; report-to="coop-endpoint""#);
269        let parsed = test_decode::<CrossOriginOpenerPolicy>(&[raw.as_str()]).expect("decode");
270        assert_eq!(parsed, v);
271    }
272
273    #[test]
274    fn parser_rejects_same_origin_plus_coep_per_spec() {
275        // The HTML Standard explicitly excludes `same-origin-plus-COEP`
276        // from valid wire values for the COOP header — it's an
277        // *effective* policy the user agent computes when a
278        // `same-origin` COOP is paired with a cross-origin-isolating
279        // COEP, not a value the server can set directly. The COOP
280        // parser must reject it.
281        assert!(test_decode::<CrossOriginOpenerPolicy>(&["same-origin-plus-COEP"]).is_none());
282        assert!(test_decode::<CrossOriginOpenerPolicy>(&["same-origin-plus-coep"]).is_none());
283    }
284
285    #[test]
286    fn parser_accepts_noopener_allow_popups() {
287        // Added 2024 — make sure the parser handles it.
288        let parsed =
289            test_decode::<CrossOriginOpenerPolicy>(&["noopener-allow-popups"]).expect("decode");
290        assert_eq!(parsed, CrossOriginOpenerPolicy::noopener_allow_popups());
291    }
292
293    #[test]
294    fn parser_rejects_unknown_token() {
295        assert!(test_decode::<CrossOriginOpenerPolicy>(&["nope"]).is_none());
296    }
297
298    #[test]
299    fn parser_accepts_unquoted_report_to() {
300        let parsed =
301            test_decode::<CrossOriginOpenerPolicy>(&["same-origin; report-to=coop-endpoint"])
302                .expect("decode unquoted");
303        assert_eq!(
304            parsed,
305            CrossOriginOpenerPolicy::same_origin().with_report_to("coop-endpoint"),
306        );
307    }
308
309    #[test]
310    fn parser_ignores_unknown_parameters() {
311        let parsed =
312            test_decode::<CrossOriginOpenerPolicy>(&["same-origin; foo=bar; report-to=\"ep\""])
313                .expect("decode unknown");
314        assert_eq!(
315            parsed,
316            CrossOriginOpenerPolicy::same_origin().with_report_to("ep"),
317        );
318    }
319
320    #[test]
321    fn report_only_round_trip() {
322        let v = CrossOriginOpenerPolicyReportOnly {
323            value: CrossOriginOpenerPolicyValue::SameOrigin,
324            report_to: Some("ep".into()),
325        };
326        let map = test_encode(v.clone());
327        let raw = map
328            .get(CrossOriginOpenerPolicyReportOnly::name())
329            .expect("set")
330            .to_str()
331            .unwrap()
332            .to_owned();
333        assert_eq!(raw, r#"same-origin; report-to="ep""#);
334        let parsed =
335            test_decode::<CrossOriginOpenerPolicyReportOnly>(&[raw.as_str()]).expect("decode");
336        assert_eq!(parsed, v);
337    }
338
339    #[test]
340    fn enforcing_and_report_only_use_distinct_header_names() {
341        assert_ne!(
342            CrossOriginOpenerPolicy::name(),
343            CrossOriginOpenerPolicyReportOnly::name(),
344        );
345    }
346}