termivar_scanner/payload_strategies/
template_jinja_uppercase_expression_pair.rs1use crate::payload_strategy::{
10 PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
11 PayloadStrategyRef, PayloadVariantRole,
12};
13
14pub const TEMPLATE_JINJA_UPPERCASE_EXPRESSION_PAIR_ID: &str =
15 "web.review.template-evaluation.jinja-compatible-uppercase-expression-pair";
16pub const TEMPLATE_JINJA_UPPERCASE_EXPRESSION_PAIR_REVISION: u32 = 1;
17pub(crate) const TEMPLATE_JINJA_UPPERCASE_FAMILY_ID: &str =
18 crate::template_evaluation_review::TEMPLATE_EVALUATION_REVIEW_FAMILY_ID;
19
20#[derive(Clone, PartialEq, Eq)]
22pub(crate) struct TemplateJinjaUppercaseProbe {
23 nonce: String,
24}
25
26impl std::fmt::Debug for TemplateJinjaUppercaseProbe {
27 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 formatter
29 .debug_struct("TemplateJinjaUppercaseProbe")
30 .field("family", &TEMPLATE_JINJA_UPPERCASE_FAMILY_ID)
31 .field("encoded_bytes", &self.seed().len())
32 .finish()
33 }
34}
35
36impl TemplateJinjaUppercaseProbe {
37 pub(crate) fn new(nonce: String) -> Option<Self> {
38 if !(16..=32).contains(&nonce.len())
39 || !nonce
40 .bytes()
41 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
42 {
43 return None;
44 }
45 Some(Self { nonce })
46 }
47
48 pub(crate) fn seed(&self) -> String {
49 format!("v1-{}", self.nonce)
50 }
51
52 pub(crate) fn control_value(&self) -> String {
53 format!("termivar-template-{}-control-end", self.nonce)
54 }
55
56 pub(crate) fn candidate_value(&self) -> String {
57 format!(
58 "termivar-template-{}-{{{{'termivar_{}'|upper}}}}-end",
59 self.nonce, self.nonce
60 )
61 }
62
63 pub(crate) fn expected_value(&self) -> String {
64 let evaluated_literal = format!("termivar_{}", self.nonce).to_ascii_uppercase();
65 format!("termivar-template-{}-{evaluated_literal}-end", self.nonce)
66 }
67
68 fn parse(seed: &[u8]) -> Option<Self> {
69 let text = std::str::from_utf8(seed).ok()?;
70 let nonce = text.strip_prefix("v1-")?;
71 if nonce.contains('-') {
72 return None;
73 }
74 Self::new(nonce.to_owned())
75 }
76}
77
78#[derive(Debug, Clone)]
79pub struct TemplateJinjaUppercaseExpressionPairStrategy {
80 reference: PayloadStrategyRef,
81}
82
83impl TemplateJinjaUppercaseExpressionPairStrategy {
84 pub fn new() -> Self {
85 Self {
86 reference: PayloadStrategyRef::new(
87 TEMPLATE_JINJA_UPPERCASE_EXPRESSION_PAIR_ID,
88 TEMPLATE_JINJA_UPPERCASE_EXPRESSION_PAIR_REVISION,
89 )
90 .expect("the template uppercase strategy identity is static and valid"),
91 }
92 }
93}
94
95impl Default for TemplateJinjaUppercaseExpressionPairStrategy {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl PayloadStrategy for TemplateJinjaUppercaseExpressionPairStrategy {
102 fn strategy_ref(&self) -> &PayloadStrategyRef {
103 &self.reference
104 }
105
106 fn derive_one(
107 &self,
108 role: PayloadVariantRole,
109 seed: &PayloadSeed,
110 limits: PayloadStrategyLimits,
111 ) -> Result<PayloadArtifact, PayloadStrategyError> {
112 let probe = TemplateJinjaUppercaseProbe::parse(seed.as_bytes())
113 .ok_or(PayloadStrategyError::DerivationFailed)?;
114 let value = match role {
115 PayloadVariantRole::Control => probe.control_value(),
116 PayloadVariantRole::Candidate => probe.candidate_value(),
117 };
118 PayloadArtifact::new(self.reference.clone(), role, value.into_bytes(), limits)
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 fn limits() -> PayloadStrategyLimits {
127 PayloadStrategyLimits::new(256, 256).unwrap()
128 }
129
130 #[test]
131 fn family_is_bounded_harmless_and_exactly_correlated() {
132 let probe = TemplateJinjaUppercaseProbe::new("a1b2c3d4e5f60708".into()).unwrap();
133 let seed = PayloadSeed::new(probe.seed().into_bytes(), limits()).unwrap();
134 let strategy = TemplateJinjaUppercaseExpressionPairStrategy::new();
135 let control = strategy
136 .derive_one(PayloadVariantRole::Control, &seed, limits())
137 .unwrap();
138 let candidate = strategy
139 .derive_one(PayloadVariantRole::Candidate, &seed, limits())
140 .unwrap();
141 assert_eq!(control.as_bytes(), probe.control_value().as_bytes());
142 assert_eq!(candidate.as_bytes(), probe.candidate_value().as_bytes());
143 assert_eq!(
144 probe.expected_value(),
145 "termivar-template-a1b2c3d4e5f60708-TERMIVAR_A1B2C3D4E5F60708-end"
146 );
147 for forbidden in [".", "[", "]", "(", ")", ";", "/", "\\"] {
148 assert!(!probe.candidate_value().contains(forbidden));
149 }
150 }
151
152 #[test]
153 fn invalid_or_unbounded_seeds_fail_closed() {
154 let strategy = TemplateJinjaUppercaseExpressionPairStrategy::new();
155 for seed in [
156 "v2-a1b2c3d4e5f60708",
157 "v1-short",
158 "v1-A1B2C3D4E5F60708",
159 "v1-a1b2c3d4e5f6070x",
160 "v1-a1b2c3d4e5f60708-extra",
161 ] {
162 let seed = PayloadSeed::new(seed.as_bytes().to_vec(), limits()).unwrap();
163 assert!(matches!(
164 strategy.derive_one(PayloadVariantRole::Candidate, &seed, limits()),
165 Err(PayloadStrategyError::DerivationFailed)
166 ));
167 }
168 }
169}