termivar_scanner/payload_strategies/
external_url_query_pair.rs1use url::Url;
9
10use crate::payload_strategy::{
11 PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
12 PayloadStrategyRef, PayloadVariantRole,
13};
14
15pub const EXTERNAL_URL_QUERY_PAIR_ID: &str = "web.review.external-url.query-pair";
17
18pub const EXTERNAL_URL_QUERY_PAIR_REVISION: u32 = 1;
20
21#[derive(Debug, Clone)]
23pub struct ExternalUrlQueryPairStrategy {
24 reference: PayloadStrategyRef,
25}
26
27impl ExternalUrlQueryPairStrategy {
28 pub fn new() -> Self {
30 let reference =
31 PayloadStrategyRef::new(EXTERNAL_URL_QUERY_PAIR_ID, EXTERNAL_URL_QUERY_PAIR_REVISION)
32 .expect("web.review.external-url.query-pair@1 is a valid strategy reference");
33 Self { reference }
34 }
35
36 fn is_valid_external_url(bytes: &[u8]) -> bool {
39 let Ok(raw) = std::str::from_utf8(bytes) else {
40 return false;
41 };
42 if raw.is_empty() || !raw.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
43 return false;
44 }
45 let Ok(parsed) = Url::parse(raw) else {
46 return false;
47 };
48 if parsed.scheme() != "https" || parsed.fragment().is_some() {
49 return false;
50 }
51 if !parsed.username().is_empty()
52 || parsed.password().is_some()
53 || Self::authority_contains_userinfo(raw)
54 {
55 return false;
56 }
57 parsed
58 .domain()
59 .is_some_and(|domain| domain.len() > ".invalid".len() && domain.ends_with(".invalid"))
60 }
61
62 fn authority_contains_userinfo(raw: &str) -> bool {
65 let Some((_, remainder)) = raw.split_once("://") else {
66 return false;
67 };
68 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
69 remainder[..authority_end].contains('@')
70 }
71}
72
73impl Default for ExternalUrlQueryPairStrategy {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl PayloadStrategy for ExternalUrlQueryPairStrategy {
80 fn strategy_ref(&self) -> &PayloadStrategyRef {
81 &self.reference
82 }
83
84 fn derive_one(
85 &self,
86 role: PayloadVariantRole,
87 seed: &PayloadSeed,
88 limits: PayloadStrategyLimits,
89 ) -> Result<PayloadArtifact, PayloadStrategyError> {
90 if !Self::is_valid_external_url(seed.as_bytes()) {
93 return Err(PayloadStrategyError::DerivationFailed);
94 }
95
96 let bytes = match role {
97 PayloadVariantRole::Control => Vec::new(),
98 PayloadVariantRole::Candidate => seed.as_bytes().to_vec(),
99 };
100 PayloadArtifact::new(self.reference.clone(), role, bytes, limits)
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use std::{sync::Arc, thread};
107
108 use super::*;
109 use crate::payload_strategy::PayloadStrategyRegistry;
110
111 fn limits() -> PayloadStrategyLimits {
112 PayloadStrategyLimits::default()
113 }
114
115 fn seed(value: &[u8]) -> PayloadSeed {
116 PayloadSeed::new(value.to_vec(), limits()).unwrap()
117 }
118
119 #[test]
120 fn reference_is_stable_and_versioned() {
121 let strategy = ExternalUrlQueryPairStrategy::new();
122 assert_eq!(strategy.strategy_ref().id(), EXTERNAL_URL_QUERY_PAIR_ID);
123 assert_eq!(
124 strategy.strategy_ref().revision(),
125 EXTERNAL_URL_QUERY_PAIR_REVISION
126 );
127 assert_eq!(
128 strategy.strategy_ref().to_string(),
129 "web.review.external-url.query-pair@1"
130 );
131 }
132
133 #[test]
134 fn control_is_empty_and_candidate_is_the_exact_seed() {
135 let strategy = ExternalUrlQueryPairStrategy::new();
136 let candidate_seed = seed(b"https://nonce.review.invalid/landing?case=7");
137
138 let control = strategy
139 .derive_one(PayloadVariantRole::Control, &candidate_seed, limits())
140 .unwrap();
141 let candidate = strategy
142 .derive_one(PayloadVariantRole::Candidate, &candidate_seed, limits())
143 .unwrap();
144
145 assert!(control.as_bytes().is_empty());
146 assert_eq!(
147 candidate.as_bytes(),
148 b"https://nonce.review.invalid/landing?case=7"
149 );
150 assert_ne!(control.receipt().sha256(), candidate.receipt().sha256());
151 }
152
153 #[test]
154 fn safe_invalid_tld_urls_with_ports_paths_and_queries_are_accepted() {
155 let strategy = ExternalUrlQueryPairStrategy::new();
156 for valid in [
157 b"https://probe.invalid".as_slice(),
158 b"https://probe.invalid/",
159 b"https://nonce.review.invalid:8443/path",
160 b"https://nonce.review.invalid/path?q=one%20two&next=%2F",
161 b"HTTPS://nonce.review.invalid/path",
162 ] {
163 let artifact = strategy
164 .derive_one(PayloadVariantRole::Candidate, &seed(valid), limits())
165 .unwrap();
166 assert_eq!(artifact.as_bytes(), valid);
167 }
168 }
169
170 #[test]
171 fn unsafe_or_non_external_urls_fail_closed_on_both_legs() {
172 let strategy = ExternalUrlQueryPairStrategy::new();
173 let invalid_seeds: Vec<Vec<u8>> = vec![
174 Vec::new(),
175 b"probe.invalid/path".to_vec(),
176 b"/relative".to_vec(),
177 b"http://probe.invalid/".to_vec(),
178 b"ftp://probe.invalid/".to_vec(),
179 b"https://invalid/".to_vec(),
180 b"https://probe.example/".to_vec(),
181 b"https://probe.invalid.example/".to_vec(),
182 b"https://127.0.0.1/".to_vec(),
183 b"https://[::1]/".to_vec(),
184 b"https://user@probe.invalid/".to_vec(),
185 b"https://user:pass@probe.invalid/".to_vec(),
186 b"https://@probe.invalid/".to_vec(),
187 b"https://probe.invalid/path#fragment".to_vec(),
188 b"https://probe.invalid/a b".to_vec(),
189 b"https://probe.invalid/\r\nX-Test: injected".to_vec(),
190 "https://pröbe.invalid/".as_bytes().to_vec(),
191 vec![0xff],
192 ];
193
194 for invalid in invalid_seeds {
195 let invalid = seed(&invalid);
196 for role in [PayloadVariantRole::Control, PayloadVariantRole::Candidate] {
197 assert!(matches!(
198 strategy.derive_one(role, &invalid, limits()),
199 Err(PayloadStrategyError::DerivationFailed)
200 ));
201 }
202 }
203 }
204
205 #[test]
206 fn output_envelope_is_enforced_after_validation() {
207 let strategy = ExternalUrlQueryPairStrategy::new();
208 let tight = PayloadStrategyLimits::new(128, 0).unwrap();
209 let candidate_seed = PayloadSeed::new(b"https://probe.invalid/".to_vec(), tight).unwrap();
210
211 assert!(strategy
212 .derive_one(PayloadVariantRole::Control, &candidate_seed, tight)
213 .unwrap()
214 .as_bytes()
215 .is_empty());
216 assert!(matches!(
217 strategy.derive_one(PayloadVariantRole::Candidate, &candidate_seed, tight),
218 Err(PayloadStrategyError::ArtifactTooLarge { .. })
219 ));
220 }
221
222 #[test]
223 fn derivation_is_repeatable_and_concurrency_safe() {
224 let strategy = Arc::new(ExternalUrlQueryPairStrategy::new());
225 let expected = strategy
226 .derive_one(
227 PayloadVariantRole::Candidate,
228 &seed(b"https://parallel.invalid/review"),
229 limits(),
230 )
231 .unwrap();
232
233 let handles: Vec<_> = (0..8)
234 .map(|_| {
235 let strategy = Arc::clone(&strategy);
236 thread::spawn(move || {
237 strategy
238 .derive_one(
239 PayloadVariantRole::Candidate,
240 &seed(b"https://parallel.invalid/review"),
241 limits(),
242 )
243 .unwrap()
244 })
245 })
246 .collect();
247
248 for handle in handles {
249 let artifact = handle.join().unwrap();
250 assert_eq!(artifact, expected);
251 assert_eq!(artifact.receipt(), expected.receipt());
252 }
253 }
254
255 #[test]
256 fn registry_resolution_preserves_the_pair_and_provenance() {
257 let strategy = ExternalUrlQueryPairStrategy::new();
258 let reference = strategy.strategy_ref().clone();
259 let mut registry = PayloadStrategyRegistry::new();
260 registry.register(Arc::new(strategy)).unwrap();
261 let candidate_seed = seed(b"https://registry.invalid/review");
262
263 let control = registry
264 .derive_one(
265 &reference,
266 PayloadVariantRole::Control,
267 &candidate_seed,
268 limits(),
269 )
270 .unwrap();
271 let candidate = registry
272 .derive_one(
273 &reference,
274 PayloadVariantRole::Candidate,
275 &candidate_seed,
276 limits(),
277 )
278 .unwrap();
279 assert!(control.as_bytes().is_empty());
280 assert_eq!(candidate.as_bytes(), b"https://registry.invalid/review");
281 assert_eq!(candidate.strategy(), &reference);
282 }
283
284 #[test]
285 fn raw_seed_never_enters_debug_or_receipt_json() {
286 let strategy = ExternalUrlQueryPairStrategy::new();
287 let artifact = strategy
288 .derive_one(
289 PayloadVariantRole::Candidate,
290 &seed(b"https://private-nonce.invalid/landing"),
291 limits(),
292 )
293 .unwrap();
294 let output = format!(
295 "{strategy:?} {artifact:?} {}",
296 serde_json::to_string(&artifact.receipt()).unwrap()
297 );
298 assert!(!output.contains("private-nonce"));
299 assert!(output.contains("<redacted>"));
300 assert_eq!(artifact.receipt().sha256().len(), 64);
301 }
302}