termivar_scanner/payload_strategies/
cors_origin_pair.rs1use url::Url;
9
10use crate::payload_strategy::{
11 PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
12 PayloadStrategyRef, PayloadVariantRole,
13};
14
15pub const CORS_ORIGIN_PAIR_ID: &str = "web.review.cors.origin-pair";
17
18pub const CORS_ORIGIN_PAIR_REVISION: u32 = 1;
20
21pub const CORS_ORIGIN_PAIR_HEADER_NAME: &str = "origin";
23
24#[derive(Debug, Clone)]
26pub struct CorsOriginPairStrategy {
27 reference: PayloadStrategyRef,
28}
29
30impl CorsOriginPairStrategy {
31 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 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 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}