Skip to main content

termivar_scanner/payload_strategies/
cors_origin_pair.rs

1//! Native matched CORS-origin payload strategy.
2//!
3//! The control leg is an empty artifact, which tells the owning HTTP executor
4//! to omit `Origin`. The candidate leg is one canonical ASCII HTTP(S) origin,
5//! copied verbatim from the seed. Derivation is pure and performs no network or
6//! knowledge-store work.
7
8use url::Url;
9
10use crate::payload_strategy::{
11    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
12    PayloadStrategyRef, PayloadVariantRole,
13};
14
15/// Stable identity of this strategy, without its revision.
16pub const CORS_ORIGIN_PAIR_ID: &str = "web.review.cors.origin-pair";
17
18/// Deterministic implementation revision materialized by this module.
19pub const CORS_ORIGIN_PAIR_REVISION: u32 = 1;
20
21/// Header varied by the candidate leg. An empty control artifact omits it.
22pub const CORS_ORIGIN_PAIR_HEADER_NAME: &str = "origin";
23
24/// A no-Origin/candidate-Origin matched pair for bounded CORS review.
25#[derive(Debug, Clone)]
26pub struct CorsOriginPairStrategy {
27    reference: PayloadStrategyRef,
28}
29
30impl CorsOriginPairStrategy {
31    /// Creates the strategy bound to its stable reference and revision.
32    pub fn new() -> Self {
33        let reference = PayloadStrategyRef::new(CORS_ORIGIN_PAIR_ID, CORS_ORIGIN_PAIR_REVISION)
34            .expect("web.review.cors.origin-pair@1 is a valid strategy reference");
35        Self { reference }
36    }
37
38    /// Accepts only the canonical ASCII serialization of an HTTP(S) origin.
39    ///
40    /// Comparing against the URL implementation's origin serialization rejects
41    /// paths, queries, fragments, credentials, control characters, and alternate
42    /// representations while preserving one exact candidate byte sequence.
43    fn is_valid_origin(bytes: &[u8]) -> bool {
44        let Ok(raw) = std::str::from_utf8(bytes) else {
45            return false;
46        };
47        if raw.is_empty() || !raw.is_ascii() {
48            return false;
49        }
50        let Ok(parsed) = Url::parse(raw) else {
51            return false;
52        };
53        if !matches!(parsed.scheme(), "http" | "https") {
54            return false;
55        }
56        parsed.origin().ascii_serialization() == raw
57    }
58}
59
60impl Default for CorsOriginPairStrategy {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl PayloadStrategy for CorsOriginPairStrategy {
67    fn strategy_ref(&self) -> &PayloadStrategyRef {
68        &self.reference
69    }
70
71    fn derive_one(
72        &self,
73        role: PayloadVariantRole,
74        seed: &PayloadSeed,
75        limits: PayloadStrategyLimits,
76    ) -> Result<PayloadArtifact, PayloadStrategyError> {
77        // Validate the candidate before deriving either leg so requesting the
78        // control first cannot make an invalid pair appear usable.
79        if !Self::is_valid_origin(seed.as_bytes()) {
80            return Err(PayloadStrategyError::DerivationFailed);
81        }
82
83        let bytes = match role {
84            PayloadVariantRole::Control => Vec::new(),
85            PayloadVariantRole::Candidate => seed.as_bytes().to_vec(),
86        };
87        PayloadArtifact::new(self.reference.clone(), role, bytes, limits)
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use std::{sync::Arc, thread};
94
95    use super::*;
96    use crate::payload_strategy::PayloadStrategyRegistry;
97
98    fn limits() -> PayloadStrategyLimits {
99        PayloadStrategyLimits::default()
100    }
101
102    fn seed(value: &[u8]) -> PayloadSeed {
103        PayloadSeed::new(value.to_vec(), limits()).unwrap()
104    }
105
106    #[test]
107    fn reference_is_stable_and_versioned() {
108        let strategy = CorsOriginPairStrategy::new();
109        assert_eq!(strategy.strategy_ref().id(), CORS_ORIGIN_PAIR_ID);
110        assert_eq!(
111            strategy.strategy_ref().revision(),
112            CORS_ORIGIN_PAIR_REVISION
113        );
114        assert_eq!(
115            strategy.strategy_ref().to_string(),
116            "web.review.cors.origin-pair@1"
117        );
118        assert_eq!(CORS_ORIGIN_PAIR_HEADER_NAME, "origin");
119    }
120
121    #[test]
122    fn control_omits_origin_and_candidate_is_the_exact_seed() {
123        let strategy = CorsOriginPairStrategy::new();
124        let candidate_seed = seed(b"https://nonce.review.invalid:8443");
125
126        let control = strategy
127            .derive_one(PayloadVariantRole::Control, &candidate_seed, limits())
128            .unwrap();
129        let candidate = strategy
130            .derive_one(PayloadVariantRole::Candidate, &candidate_seed, limits())
131            .unwrap();
132
133        assert!(control.as_bytes().is_empty());
134        assert_eq!(candidate.as_bytes(), b"https://nonce.review.invalid:8443");
135        assert_ne!(control.receipt().sha256(), candidate.receipt().sha256());
136    }
137
138    #[test]
139    fn canonical_http_and_https_origins_are_accepted() {
140        let strategy = CorsOriginPairStrategy::new();
141        for valid in [
142            b"http://example.test".as_slice(),
143            b"https://example.test",
144            b"https://example.test:8443",
145            b"http://[2001:db8::1]:8080",
146        ] {
147            let artifact = strategy
148                .derive_one(PayloadVariantRole::Candidate, &seed(valid), limits())
149                .unwrap();
150            assert_eq!(artifact.as_bytes(), valid);
151        }
152    }
153
154    #[test]
155    fn non_origins_fail_closed_on_both_legs() {
156        let strategy = CorsOriginPairStrategy::new();
157        let invalid_seeds: Vec<Vec<u8>> = vec![
158            Vec::new(),
159            b"example.test".to_vec(),
160            b"/relative".to_vec(),
161            b"ftp://example.test".to_vec(),
162            b"https://example.test/".to_vec(),
163            b"https://example.test/path".to_vec(),
164            b"https://example.test?q=1".to_vec(),
165            b"https://example.test#fragment".to_vec(),
166            b"https://user@example.test".to_vec(),
167            b"https://example.test:443".to_vec(),
168            b"HTTPS://example.test".to_vec(),
169            b"https://EXAMPLE.test".to_vec(),
170            b"https://example.test\r\nX-Test: injected".to_vec(),
171            "https://exämple.test".as_bytes().to_vec(),
172            vec![0xff],
173        ];
174
175        for invalid in invalid_seeds {
176            let invalid = seed(&invalid);
177            for role in [PayloadVariantRole::Control, PayloadVariantRole::Candidate] {
178                assert!(matches!(
179                    strategy.derive_one(role, &invalid, limits()),
180                    Err(PayloadStrategyError::DerivationFailed)
181                ));
182            }
183        }
184    }
185
186    #[test]
187    fn output_envelope_is_enforced_after_validation() {
188        let strategy = CorsOriginPairStrategy::new();
189        let tight = PayloadStrategyLimits::new(128, 0).unwrap();
190        let candidate_seed = PayloadSeed::new(b"https://probe.invalid".to_vec(), tight).unwrap();
191
192        assert!(strategy
193            .derive_one(PayloadVariantRole::Control, &candidate_seed, tight)
194            .unwrap()
195            .as_bytes()
196            .is_empty());
197        assert!(matches!(
198            strategy.derive_one(PayloadVariantRole::Candidate, &candidate_seed, tight),
199            Err(PayloadStrategyError::ArtifactTooLarge { .. })
200        ));
201    }
202
203    #[test]
204    fn derivation_is_repeatable_and_concurrency_safe() {
205        let strategy = Arc::new(CorsOriginPairStrategy::new());
206        let expected = strategy
207            .derive_one(
208                PayloadVariantRole::Candidate,
209                &seed(b"https://parallel.invalid"),
210                limits(),
211            )
212            .unwrap();
213
214        let handles: Vec<_> = (0..8)
215            .map(|_| {
216                let strategy = Arc::clone(&strategy);
217                thread::spawn(move || {
218                    strategy
219                        .derive_one(
220                            PayloadVariantRole::Candidate,
221                            &seed(b"https://parallel.invalid"),
222                            limits(),
223                        )
224                        .unwrap()
225                })
226            })
227            .collect();
228
229        for handle in handles {
230            let artifact = handle.join().unwrap();
231            assert_eq!(artifact, expected);
232            assert_eq!(artifact.receipt(), expected.receipt());
233        }
234    }
235
236    #[test]
237    fn registry_resolution_preserves_the_pair_and_provenance() {
238        let strategy = CorsOriginPairStrategy::new();
239        let reference = strategy.strategy_ref().clone();
240        let mut registry = PayloadStrategyRegistry::new();
241        registry.register(Arc::new(strategy)).unwrap();
242        let candidate_seed = seed(b"https://registry.invalid");
243
244        let control = registry
245            .derive_one(
246                &reference,
247                PayloadVariantRole::Control,
248                &candidate_seed,
249                limits(),
250            )
251            .unwrap();
252        let candidate = registry
253            .derive_one(
254                &reference,
255                PayloadVariantRole::Candidate,
256                &candidate_seed,
257                limits(),
258            )
259            .unwrap();
260        assert!(control.as_bytes().is_empty());
261        assert_eq!(candidate.as_bytes(), b"https://registry.invalid");
262        assert_eq!(candidate.strategy(), &reference);
263    }
264
265    #[test]
266    fn raw_seed_never_enters_debug_or_receipt_json() {
267        let strategy = CorsOriginPairStrategy::new();
268        let artifact = strategy
269            .derive_one(
270                PayloadVariantRole::Candidate,
271                &seed(b"https://private-nonce.invalid"),
272                limits(),
273            )
274            .unwrap();
275        let output = format!(
276            "{strategy:?} {artifact:?} {}",
277            serde_json::to_string(&artifact.receipt()).unwrap()
278        );
279        assert!(!output.contains("private-nonce"));
280        assert!(output.contains("<redacted>"));
281        assert_eq!(artifact.receipt().sha256().len(), 64);
282    }
283}