Skip to main content

wafrift_encoding/tamper/
mod.rs

1//! Payload tampering strategies — advanced payload transformations beyond basic encoding.
2//!
3//! Tamper strategies combine multiple transformations in sophisticated ways
4//! to bypass WAF rules that simple encoding cannot evade.
5
6use std::collections::HashMap;
7
8mod builtins;
9mod config;
10
11pub use builtins::*;
12pub use config::{StrategyConfig, TamperConfig};
13
14/// A tamper strategy transforms a payload for WAF evasion.
15///
16/// Unlike basic encoding, tamper strategies may use contextual knowledge
17/// about the target (SQL, XSS, etc.) to apply targeted transformations.
18pub trait TamperStrategy: Send + Sync {
19    /// Returns the unique name of this tamper strategy.
20    fn name(&self) -> &'static str;
21
22    /// Returns a description of what this strategy does.
23    fn description(&self) -> &'static str;
24
25    /// Transforms the input payload.
26    ///
27    /// # Arguments
28    /// * `payload` - The input payload to transform
29    /// * `context` - Optional context about the payload (e.g., "sql", "xss")
30    fn tamper(&self, payload: &str, context: Option<&str>) -> String;
31
32    /// Transforms the input payload with custom parameters.
33    ///
34    /// Default implementation delegates to [`Self::tamper`].
35    fn tamper_with_params(
36        &self,
37        payload: &str,
38        context: Option<&str>,
39        _params: &HashMap<String, toml::Value>,
40    ) -> String {
41        self.tamper(payload, context)
42    }
43
44    /// Returns the aggressiveness score (0.0 = mild, 1.0 = extreme).
45    fn aggressiveness(&self) -> f64;
46}
47
48/// Registry of all available tamper strategies.
49#[derive(Default)]
50pub struct TamperRegistry {
51    strategies: HashMap<String, Box<dyn TamperStrategy>>,
52}
53
54/// Built-in tamper strategy names.
55const DEFAULT_NAMES: &[&str] = &[
56    "url_encode",
57    "double_url_encode",
58    "unicode_escape",
59    "html_entity",
60    "html_entity_variants",
61    "math_bold",
62    "json_unicode_alnum",
63    "sql_concat_split",
64    "sql_char_decompose",
65    "pg_chr_decompose",
66    "sql_adjacent_string_concat",
67    "case_alternation",
68    "random_case",
69    "whitespace_insertion",
70    "sql_comment",
71    "null_byte",
72    "overlong_utf8",
73    "base64",
74    "hex_encode",
75    // ── Frontier 2025-2026 additions ─────────────────────────
76    "zero_width_inject",
77    "postgres_dollar_quote",
78    "mysql_versioned_comment_wrap",
79    "bracket_confusable",
80    "hex_literal_keyword",
81    "bell_separator",
82    "mxss_namespace_wrap",
83    "json_dup_key",
84    "ct_starvation",
85];
86
87impl TamperRegistry {
88    /// Creates a new empty registry.
89    #[must_use]
90    pub fn new() -> Self {
91        Self {
92            strategies: HashMap::new(),
93        }
94    }
95
96    /// Creates a new registry with all built-in strategies registered.
97    #[must_use]
98    pub fn with_defaults() -> Self {
99        let mut registry = Self::new();
100        for name in DEFAULT_NAMES {
101            match *name {
102                "url_encode" => registry.register(Box::new(UrlEncodeTamper)),
103                "double_url_encode" => registry.register(Box::new(DoubleUrlEncodeTamper)),
104                "unicode_escape" => registry.register(Box::new(UnicodeEscapeTamper)),
105                "html_entity" => registry.register(Box::new(HtmlEntityTamper)),
106                "html_entity_variants" => {
107                    registry.register(Box::new(HtmlEntityVariantsTamper));
108                }
109                "math_bold" => registry.register(Box::new(MathBoldTamper)),
110                "json_unicode_alnum" => {
111                    registry.register(Box::new(JsonUnicodeAlnumTamper));
112                }
113                "sql_concat_split" => registry.register(Box::new(SqlConcatSplitTamper)),
114                "sql_char_decompose" => {
115                    registry.register(Box::new(SqlCharDecomposeTamper));
116                }
117                "pg_chr_decompose" => registry.register(Box::new(PgChrDecomposeTamper)),
118                "sql_adjacent_string_concat" => {
119                    registry.register(Box::new(SqlAdjacentStringConcatTamper));
120                }
121                "case_alternation" => registry.register(Box::new(CaseAlternationTamper)),
122                "random_case" => registry.register(Box::new(RandomCaseTamper)),
123                "whitespace_insertion" => registry.register(Box::new(WhitespaceInsertionTamper)),
124                "sql_comment" => registry.register(Box::new(SqlCommentTamper)),
125                "null_byte" => registry.register(Box::new(NullByteTamper)),
126                "overlong_utf8" => registry.register(Box::new(OverlongUtf8Tamper)),
127                "base64" => registry.register(Box::new(Base64Tamper)),
128                "hex_encode" => registry.register(Box::new(HexEncodeTamper)),
129                "zero_width_inject" => registry.register(Box::new(ZeroWidthInjectTamper)),
130                "postgres_dollar_quote" => {
131                    registry.register(Box::new(PostgresDollarQuoteTamper));
132                }
133                "mysql_versioned_comment_wrap" => {
134                    registry.register(Box::new(MysqlVersionedCommentWrapTamper));
135                }
136                "bracket_confusable" => registry.register(Box::new(BracketConfusableTamper)),
137                "hex_literal_keyword" => registry.register(Box::new(HexLiteralKeywordTamper)),
138                "bell_separator" => registry.register(Box::new(BellSeparatorTamper)),
139                "mxss_namespace_wrap" => {
140                    registry.register(Box::new(MxssNamespaceWrapTamper));
141                }
142                "json_dup_key" => registry.register(Box::new(JsonDupKeyTamper)),
143                "ct_starvation" => registry.register(Box::new(CtStarvationTamper)),
144                _ => {}
145            }
146        }
147        registry
148    }
149
150    /// Registers a tamper strategy.
151    pub fn register(&mut self, strategy: Box<dyn TamperStrategy>) {
152        self.strategies
153            .insert(strategy.name().to_string(), strategy);
154    }
155
156    /// Unregisters a tamper strategy by name.
157    pub fn unregister(&mut self, name: &str) -> Option<Box<dyn TamperStrategy>> {
158        self.strategies.remove(name)
159    }
160
161    /// Clears all registered strategies.
162    pub fn clear(&mut self) {
163        self.strategies.clear();
164    }
165
166    /// Gets a strategy by name.
167    #[must_use]
168    pub fn get(&self, name: &str) -> Option<&dyn TamperStrategy> {
169        self.strategies.get(name).map(std::convert::AsRef::as_ref)
170    }
171
172    /// Returns all registered strategy names.
173    #[must_use]
174    pub fn names(&self) -> Vec<&str> {
175        self.strategies
176            .keys()
177            .map(std::string::String::as_str)
178            .collect()
179    }
180
181    /// Returns all strategies sorted by aggressiveness (least to most).
182    #[must_use]
183    pub fn by_aggressiveness(&self) -> Vec<&dyn TamperStrategy> {
184        let mut strategies: Vec<&dyn TamperStrategy> = self
185            .strategies
186            .values()
187            .map(std::convert::AsRef::as_ref)
188            .collect();
189        strategies.sort_by(|a, b| {
190            let a_score = if a.aggressiveness().is_nan() {
191                1.0
192            } else {
193                a.aggressiveness()
194            };
195            let b_score = if b.aggressiveness().is_nan() {
196                1.0
197            } else {
198                b.aggressiveness()
199            };
200            a_score
201                .partial_cmp(&b_score)
202                .unwrap_or(std::cmp::Ordering::Equal)
203        });
204        strategies
205    }
206
207    /// Applies a named strategy to a payload.
208    ///
209    /// # Errors
210    /// Returns an error if the strategy is not found.
211    pub fn tamper_with(
212        &self,
213        name: &str,
214        payload: &str,
215        context: Option<&str>,
216    ) -> Result<String, TamperError> {
217        self.get(name)
218            .map(|s| s.tamper(payload, context))
219            .ok_or_else(|| TamperError::StrategyNotFound(name.to_string()))
220    }
221
222    /// Applies a named strategy with parameters.
223    ///
224    /// # Errors
225    /// Returns an error if the strategy is not found.
226    pub fn tamper_with_params(
227        &self,
228        name: &str,
229        payload: &str,
230        context: Option<&str>,
231        params: &HashMap<String, toml::Value>,
232    ) -> Result<String, TamperError> {
233        self.get(name)
234            .map(|s| s.tamper_with_params(payload, context, params))
235            .ok_or_else(|| TamperError::StrategyNotFound(name.to_string()))
236    }
237}
238
239/// Errors that can occur during tampering.
240#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
241pub enum TamperError {
242    /// The requested strategy was not found in the registry.
243    #[error("Strategy not found: {0}")]
244    StrategyNotFound(String),
245    /// The TOML configuration is invalid.
246    #[error("Invalid configuration: {0}")]
247    InvalidConfig(String),
248    /// Failed to load strategies from file.
249    #[error("Failed to load strategies: {0}")]
250    LoadError(String),
251}
252
253/// Creates a registry with all default strategies.
254#[must_use]
255pub fn default_registry() -> TamperRegistry {
256    TamperRegistry::with_defaults()
257}
258
259/// Apply a single tamper strategy by name.
260///
261/// # Errors
262/// Returns an error if the strategy name is not recognized.
263pub fn tamper(strategy: &str, payload: &str, context: Option<&str>) -> Result<String, TamperError> {
264    let registry = default_registry();
265    registry.tamper_with(strategy, payload, context)
266}
267
268/// Get all available tamper strategy names.
269#[must_use]
270pub fn all_tamper_names() -> &'static [&'static str] {
271    DEFAULT_NAMES
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn registry_with_defaults_has_strategies() {
280        let registry = TamperRegistry::with_defaults();
281        assert!(!registry.names().is_empty());
282        assert!(registry.get("url_encode").is_some());
283        assert!(registry.get("base64").is_some());
284    }
285
286    #[test]
287    fn registry_lookup_fails_for_unknown() {
288        let registry = TamperRegistry::with_defaults();
289        assert!(registry.get("unknown_strategy").is_none());
290    }
291
292    #[test]
293    fn tamper_with_error_for_unknown() {
294        let registry = TamperRegistry::with_defaults();
295        let result = registry.tamper_with("unknown", "payload", None);
296        assert!(matches!(result, Err(TamperError::StrategyNotFound(_))));
297    }
298
299    #[test]
300    fn aggressiveness_sorting() {
301        let registry = TamperRegistry::with_defaults();
302        let strategies = registry.by_aggressiveness();
303        for i in 1..strategies.len() {
304            assert!(
305                strategies[i - 1].aggressiveness() <= strategies[i].aggressiveness(),
306                "Strategies should be sorted by aggressiveness"
307            );
308        }
309    }
310
311    #[test]
312    fn unregister_removes_strategy() {
313        let mut registry = TamperRegistry::with_defaults();
314        assert!(registry.get("url_encode").is_some());
315        let removed = registry.unregister("url_encode");
316        assert!(removed.is_some());
317        assert!(registry.get("url_encode").is_none());
318    }
319
320    #[test]
321    fn clear_removes_all() {
322        let mut registry = TamperRegistry::with_defaults();
323        registry.clear();
324        assert!(registry.names().is_empty());
325    }
326
327    #[test]
328    fn nan_aggressiveness_treated_as_one() {
329        struct NaNStrategy;
330        impl TamperStrategy for NaNStrategy {
331            fn name(&self) -> &'static str {
332                "nan_test"
333            }
334            fn description(&self) -> &'static str {
335                "test"
336            }
337            fn tamper(&self, _p: &str, _c: Option<&str>) -> String {
338                "test".to_string()
339            }
340            fn aggressiveness(&self) -> f64 {
341                f64::NAN
342            }
343        }
344        let mut registry = TamperRegistry::new();
345        registry.register(Box::new(NaNStrategy));
346        let sorted = registry.by_aggressiveness();
347        assert_eq!(sorted.len(), 1);
348    }
349
350    #[test]
351    fn all_tamper_names_static() {
352        let names = all_tamper_names();
353        assert!(!names.is_empty());
354        assert!(names.contains(&"url_encode"));
355    }
356
357    #[test]
358    fn tamper_error_display() {
359        let err = TamperError::StrategyNotFound("test".to_string());
360        assert_eq!(format!("{err}"), "Strategy not found: test");
361    }
362
363    #[test]
364    fn default_registry_function() {
365        let registry = default_registry();
366        assert!(!registry.names().is_empty());
367    }
368
369    #[test]
370    fn convenience_tamper_function() {
371        let result = tamper("url_encode", "test!", None);
372        assert!(result.is_ok());
373        assert_eq!(result.unwrap(), "test%21");
374    }
375
376    #[test]
377    fn convenience_tamper_function_error() {
378        let result = tamper("unknown", "test", None);
379        assert!(result.is_err());
380    }
381}