Skip to main content

termivar_scanner/payload_strategies/
external_url_query_pair.rs

1//! Native matched external-URL query payload strategy.
2//!
3//! The control leg is empty, while the candidate leg copies one validated
4//! absolute HTTPS URL under the reserved `.invalid` top-level domain. This
5//! keeps the strategy inert and deterministic: it derives bytes only and never
6//! follows or contacts the destination.
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 EXTERNAL_URL_QUERY_PAIR_ID: &str = "web.review.external-url.query-pair";
17
18/// Deterministic implementation revision materialized by this module.
19pub const EXTERNAL_URL_QUERY_PAIR_REVISION: u32 = 1;
20
21/// A query-free control/external-URL candidate pair for redirect or reflection review.
22#[derive(Debug, Clone)]
23pub struct ExternalUrlQueryPairStrategy {
24    reference: PayloadStrategyRef,
25}
26
27impl ExternalUrlQueryPairStrategy {
28    /// Creates the strategy bound to its stable reference and revision.
29    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    /// Accepts a visible-ASCII absolute HTTPS URL below `.invalid` with no
37    /// credentials or fragment.
38    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    /// Detects even empty user-info (`https://@host/`), which URL accessors
63    /// otherwise represent as an empty username and absent password.
64    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        // A control leg is useful only when its paired candidate is valid, so
91        // validate the seed before deriving either role.
92        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}