Skip to main content

termivar_scanner/payload_strategies/
http_header_control_pair.rs

1//! First built-in payload strategy: `http.header.control-pair@1`.
2//!
3//! This strategy derives a matched control/candidate pair for a single benign
4//! request header so a strategy-aware capability executor can measure whether a
5//! target responds differently to exactly one controlled header change. It is
6//! deliberately the lowest-risk built-in strategy: it emits only visible ASCII
7//! header-value bytes and can never introduce control characters, so it cannot
8//! perform header injection or request splitting.
9//!
10//! The strategy operates on a fixed header for this revision. The control leg is
11//! a conventional, seed-independent baseline (the "normal profile"); the
12//! candidate leg is that baseline plus one controlled, seed-derived variation.
13//! Derivation is a pure function of `(role, seed)` — no clocks, randomness,
14//! knowledge state, or transport.
15
16use crate::payload_strategy::{
17    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
18    PayloadStrategyRef, PayloadVariantRole,
19};
20
21/// Stable identity of this strategy, without its revision.
22pub const HTTP_HEADER_CONTROL_PAIR_ID: &str = "http.header.control-pair";
23
24/// Deterministic implementation revision materialized by this module.
25pub const HTTP_HEADER_CONTROL_PAIR_REVISION: u32 = 1;
26
27/// Request header this revision varies. A strategy-aware executor applies the
28/// derived artifact bytes as the value of this header.
29pub const HTTP_HEADER_CONTROL_PAIR_HEADER_NAME: &str = "accept";
30
31/// Seed-independent baseline value used by the control leg.
32const BASELINE_VALUE: &[u8] = b"*/*";
33
34/// Prefix the candidate leg prepends before the controlled seed variation.
35const CANDIDATE_PREFIX: &[u8] = b"*/*, ";
36
37/// First built-in [`PayloadStrategy`]: a single-header control/candidate pair.
38#[derive(Debug, Clone)]
39pub struct HttpHeaderControlPairStrategy {
40    reference: PayloadStrategyRef,
41}
42
43impl HttpHeaderControlPairStrategy {
44    /// Creates the strategy bound to its stable reference and revision.
45    ///
46    /// The reference identity is a compile-time constant, so construction only
47    /// fails if the shared [`PayloadStrategyRef`] validation contract changes;
48    /// that is treated as an unrecoverable programmer error.
49    pub fn new() -> Self {
50        let reference = PayloadStrategyRef::new(
51            HTTP_HEADER_CONTROL_PAIR_ID,
52            HTTP_HEADER_CONTROL_PAIR_REVISION,
53        )
54        .expect("http.header.control-pair@1 is a valid strategy reference");
55        Self { reference }
56    }
57
58    /// Returns whether `bytes` are a safe, non-empty HTTP header value.
59    ///
60    /// Accepts a single interior run of visible ASCII and spaces, rejecting
61    /// empty values, leading or trailing spaces, control characters (including
62    /// CR and LF), and any non-ASCII byte. This keeps every derived candidate a
63    /// value a host transport can attach verbatim and forecloses request
64    /// splitting at the derivation boundary.
65    fn is_safe_header_value(bytes: &[u8]) -> bool {
66        if bytes.is_empty() {
67            return false;
68        }
69        if bytes.first() == Some(&b' ') || bytes.last() == Some(&b' ') {
70            return false;
71        }
72        bytes
73            .iter()
74            .all(|&byte| byte == b' ' || (0x21..=0x7e).contains(&byte))
75    }
76}
77
78impl Default for HttpHeaderControlPairStrategy {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl PayloadStrategy for HttpHeaderControlPairStrategy {
85    fn strategy_ref(&self) -> &PayloadStrategyRef {
86        &self.reference
87    }
88
89    fn derive_one(
90        &self,
91        role: PayloadVariantRole,
92        seed: &PayloadSeed,
93        limits: PayloadStrategyLimits,
94    ) -> Result<PayloadArtifact, PayloadStrategyError> {
95        // Both legs require a valid candidate token up front so the pair is
96        // rejected deterministically regardless of which leg is derived first.
97        if !Self::is_safe_header_value(seed.as_bytes()) {
98            return Err(PayloadStrategyError::DerivationFailed);
99        }
100
101        let bytes = match role {
102            PayloadVariantRole::Control => BASELINE_VALUE.to_vec(),
103            PayloadVariantRole::Candidate => {
104                let mut bytes = Vec::with_capacity(CANDIDATE_PREFIX.len() + seed.len());
105                bytes.extend_from_slice(CANDIDATE_PREFIX);
106                bytes.extend_from_slice(seed.as_bytes());
107                bytes
108            },
109        };
110
111        PayloadArtifact::new(self.reference.clone(), role, bytes, limits)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use std::{sync::Arc, thread};
118
119    use super::*;
120    use crate::payload_strategy::PayloadStrategyRegistry;
121
122    fn limits() -> PayloadStrategyLimits {
123        PayloadStrategyLimits::default()
124    }
125
126    fn seed(value: &[u8]) -> PayloadSeed {
127        PayloadSeed::new(value.to_vec(), limits()).unwrap()
128    }
129
130    #[test]
131    fn reference_is_stable_and_versioned() {
132        let strategy = HttpHeaderControlPairStrategy::new();
133        assert_eq!(strategy.strategy_ref().id(), HTTP_HEADER_CONTROL_PAIR_ID);
134        assert_eq!(
135            strategy.strategy_ref().revision(),
136            HTTP_HEADER_CONTROL_PAIR_REVISION
137        );
138        assert_eq!(
139            strategy.strategy_ref().to_string(),
140            "http.header.control-pair@1"
141        );
142    }
143
144    #[test]
145    fn control_is_seed_independent_and_candidate_embeds_seed() {
146        let strategy = HttpHeaderControlPairStrategy::new();
147
148        let control_a = strategy
149            .derive_one(PayloadVariantRole::Control, &seed(b"one"), limits())
150            .unwrap();
151        let control_b = strategy
152            .derive_one(PayloadVariantRole::Control, &seed(b"different"), limits())
153            .unwrap();
154        assert_eq!(control_a.as_bytes(), b"*/*");
155        assert_eq!(control_a.as_bytes(), control_b.as_bytes());
156
157        let candidate = strategy
158            .derive_one(
159                PayloadVariantRole::Candidate,
160                &seed(b"application/json"),
161                limits(),
162            )
163            .unwrap();
164        assert_eq!(candidate.as_bytes(), b"*/*, application/json");
165        assert_ne!(candidate.as_bytes(), control_a.as_bytes());
166    }
167
168    #[test]
169    fn same_input_is_deterministic_across_repeats() {
170        let strategy = HttpHeaderControlPairStrategy::new();
171        let first = strategy
172            .derive_one(PayloadVariantRole::Candidate, &seed(b"text/html"), limits())
173            .unwrap();
174        let second = strategy
175            .derive_one(PayloadVariantRole::Candidate, &seed(b"text/html"), limits())
176            .unwrap();
177        assert_eq!(first, second);
178        assert_eq!(first.receipt(), second.receipt());
179    }
180
181    #[test]
182    fn derivation_is_deterministic_under_concurrency() {
183        let strategy = Arc::new(HttpHeaderControlPairStrategy::new());
184        let expected = strategy
185            .derive_one(
186                PayloadVariantRole::Candidate,
187                &seed(b"probe-token"),
188                limits(),
189            )
190            .unwrap();
191
192        let handles: Vec<_> = (0..8)
193            .map(|_| {
194                let strategy = Arc::clone(&strategy);
195                thread::spawn(move || {
196                    strategy
197                        .derive_one(
198                            PayloadVariantRole::Candidate,
199                            &seed(b"probe-token"),
200                            limits(),
201                        )
202                        .unwrap()
203                        .receipt()
204                })
205            })
206            .collect();
207
208        for handle in handles {
209            assert_eq!(handle.join().unwrap(), expected.receipt());
210        }
211    }
212
213    #[test]
214    fn unsafe_seeds_fail_closed_on_both_legs() {
215        let strategy = HttpHeaderControlPairStrategy::new();
216        for unsafe_seed in [
217            b"".as_slice(),
218            b" leading",
219            b"trailing ",
220            b"line\nbreak",
221            b"carriage\rreturn",
222            b"tab\ttoken",
223            b"null\0byte",
224        ] {
225            let seed = PayloadSeed::new(unsafe_seed.to_vec(), limits()).unwrap();
226            for role in [PayloadVariantRole::Control, PayloadVariantRole::Candidate] {
227                assert!(
228                    matches!(
229                        strategy.derive_one(role, &seed, limits()),
230                        Err(PayloadStrategyError::DerivationFailed)
231                    ),
232                    "expected fail-closed derivation for seed {unsafe_seed:?} role {role:?}"
233                );
234            }
235        }
236    }
237
238    #[test]
239    fn oversized_candidate_value_fails_closed() {
240        let strategy = HttpHeaderControlPairStrategy::new();
241        // Prefix is 5 bytes; a 4-byte artifact ceiling cannot hold prefix+seed.
242        let tight = PayloadStrategyLimits::new(64, 4).unwrap();
243        let seed = PayloadSeed::new(b"json".to_vec(), tight).unwrap();
244        assert!(matches!(
245            strategy.derive_one(PayloadVariantRole::Candidate, &seed, tight),
246            Err(PayloadStrategyError::ArtifactTooLarge { .. })
247        ));
248    }
249
250    #[test]
251    fn resolves_and_revalidates_through_registry() {
252        let strategy = HttpHeaderControlPairStrategy::new();
253        let reference = strategy.strategy_ref().clone();
254        let mut registry = PayloadStrategyRegistry::new();
255        registry.register(Arc::new(strategy)).unwrap();
256
257        let artifact = registry
258            .derive_one(
259                &reference,
260                PayloadVariantRole::Candidate,
261                &seed(b"application/json"),
262                limits(),
263            )
264            .unwrap();
265        assert_eq!(artifact.strategy(), &reference);
266        assert_eq!(artifact.role(), PayloadVariantRole::Candidate);
267        assert_eq!(artifact.as_bytes(), b"*/*, application/json");
268    }
269
270    #[test]
271    fn raw_seed_never_enters_receipt_json() {
272        let strategy = HttpHeaderControlPairStrategy::new();
273        let artifact = strategy
274            .derive_one(
275                PayloadVariantRole::Candidate,
276                &seed(b"secret-token-value"),
277                limits(),
278            )
279            .unwrap();
280        let receipt_json = serde_json::to_string(&artifact.receipt()).unwrap();
281        let debug = format!("{artifact:?}");
282        assert!(!receipt_json.contains("secret-token-value"));
283        assert!(!debug.contains("secret-token-value"));
284        assert_eq!(artifact.receipt().sha256().len(), 64);
285    }
286}