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    "case_alternation",
61    "random_case",
62    "whitespace_insertion",
63    "sql_comment",
64    "null_byte",
65    "overlong_utf8",
66    "base64",
67    "hex_encode",
68];
69
70impl TamperRegistry {
71    /// Creates a new empty registry.
72    #[must_use]
73    pub fn new() -> Self {
74        Self {
75            strategies: HashMap::new(),
76        }
77    }
78
79    /// Creates a new registry with all built-in strategies registered.
80    #[must_use]
81    pub fn with_defaults() -> Self {
82        let mut registry = Self::new();
83        for name in DEFAULT_NAMES {
84            match *name {
85                "url_encode" => registry.register(Box::new(UrlEncodeTamper)),
86                "double_url_encode" => registry.register(Box::new(DoubleUrlEncodeTamper)),
87                "unicode_escape" => registry.register(Box::new(UnicodeEscapeTamper)),
88                "html_entity" => registry.register(Box::new(HtmlEntityTamper)),
89                "case_alternation" => registry.register(Box::new(CaseAlternationTamper)),
90                "random_case" => registry.register(Box::new(RandomCaseTamper)),
91                "whitespace_insertion" => registry.register(Box::new(WhitespaceInsertionTamper)),
92                "sql_comment" => registry.register(Box::new(SqlCommentTamper)),
93                "null_byte" => registry.register(Box::new(NullByteTamper)),
94                "overlong_utf8" => registry.register(Box::new(OverlongUtf8Tamper)),
95                "base64" => registry.register(Box::new(Base64Tamper)),
96                "hex_encode" => registry.register(Box::new(HexEncodeTamper)),
97                _ => {}
98            }
99        }
100        registry
101    }
102
103    /// Registers a tamper strategy.
104    pub fn register(&mut self, strategy: Box<dyn TamperStrategy>) {
105        self.strategies
106            .insert(strategy.name().to_string(), strategy);
107    }
108
109    /// Unregisters a tamper strategy by name.
110    pub fn unregister(&mut self, name: &str) -> Option<Box<dyn TamperStrategy>> {
111        self.strategies.remove(name)
112    }
113
114    /// Clears all registered strategies.
115    pub fn clear(&mut self) {
116        self.strategies.clear();
117    }
118
119    /// Gets a strategy by name.
120    #[must_use]
121    pub fn get(&self, name: &str) -> Option<&dyn TamperStrategy> {
122        self.strategies.get(name).map(std::convert::AsRef::as_ref)
123    }
124
125    /// Returns all registered strategy names.
126    #[must_use]
127    pub fn names(&self) -> Vec<&str> {
128        self.strategies.keys().map(std::string::String::as_str).collect()
129    }
130
131    /// Returns all strategies sorted by aggressiveness (least to most).
132    #[must_use]
133    pub fn by_aggressiveness(&self) -> Vec<&dyn TamperStrategy> {
134        let mut strategies: Vec<&dyn TamperStrategy> =
135            self.strategies.values().map(std::convert::AsRef::as_ref).collect();
136        strategies.sort_by(|a, b| {
137            let a_score = if a.aggressiveness().is_nan() {
138                1.0
139            } else {
140                a.aggressiveness()
141            };
142            let b_score = if b.aggressiveness().is_nan() {
143                1.0
144            } else {
145                b.aggressiveness()
146            };
147            a_score
148                .partial_cmp(&b_score)
149                .unwrap_or(std::cmp::Ordering::Equal)
150        });
151        strategies
152    }
153
154    /// Applies a named strategy to a payload.
155    ///
156    /// # Errors
157    /// Returns an error if the strategy is not found.
158    pub fn tamper_with(
159        &self,
160        name: &str,
161        payload: &str,
162        context: Option<&str>,
163    ) -> Result<String, TamperError> {
164        self.get(name)
165            .map(|s| s.tamper(payload, context))
166            .ok_or_else(|| TamperError::StrategyNotFound(name.to_string()))
167    }
168
169    /// Applies a named strategy with parameters.
170    ///
171    /// # Errors
172    /// Returns an error if the strategy is not found.
173    pub fn tamper_with_params(
174        &self,
175        name: &str,
176        payload: &str,
177        context: Option<&str>,
178        params: &HashMap<String, toml::Value>,
179    ) -> Result<String, TamperError> {
180        self.get(name)
181            .map(|s| s.tamper_with_params(payload, context, params))
182            .ok_or_else(|| TamperError::StrategyNotFound(name.to_string()))
183    }
184}
185
186/// Errors that can occur during tampering.
187#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
188pub enum TamperError {
189    /// The requested strategy was not found in the registry.
190    #[error("Strategy not found: {0}")]
191    StrategyNotFound(String),
192    /// The TOML configuration is invalid.
193    #[error("Invalid configuration: {0}")]
194    InvalidConfig(String),
195    /// Failed to load strategies from file.
196    #[error("Failed to load strategies: {0}")]
197    LoadError(String),
198}
199
200/// Creates a registry with all default strategies.
201#[must_use]
202pub fn default_registry() -> TamperRegistry {
203    TamperRegistry::with_defaults()
204}
205
206/// Apply a single tamper strategy by name.
207///
208/// # Errors
209/// Returns an error if the strategy name is not recognized.
210pub fn tamper(strategy: &str, payload: &str, context: Option<&str>) -> Result<String, TamperError> {
211    let registry = default_registry();
212    registry.tamper_with(strategy, payload, context)
213}
214
215/// Get all available tamper strategy names.
216#[must_use]
217pub fn all_tamper_names() -> &'static [&'static str] {
218    DEFAULT_NAMES
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn registry_with_defaults_has_strategies() {
227        let registry = TamperRegistry::with_defaults();
228        assert!(!registry.names().is_empty());
229        assert!(registry.get("url_encode").is_some());
230        assert!(registry.get("base64").is_some());
231    }
232
233    #[test]
234    fn registry_lookup_fails_for_unknown() {
235        let registry = TamperRegistry::with_defaults();
236        assert!(registry.get("unknown_strategy").is_none());
237    }
238
239    #[test]
240    fn tamper_with_error_for_unknown() {
241        let registry = TamperRegistry::with_defaults();
242        let result = registry.tamper_with("unknown", "payload", None);
243        assert!(matches!(result, Err(TamperError::StrategyNotFound(_))));
244    }
245
246    #[test]
247    fn aggressiveness_sorting() {
248        let registry = TamperRegistry::with_defaults();
249        let strategies = registry.by_aggressiveness();
250        for i in 1..strategies.len() {
251            assert!(
252                strategies[i - 1].aggressiveness() <= strategies[i].aggressiveness(),
253                "Strategies should be sorted by aggressiveness"
254            );
255        }
256    }
257
258    #[test]
259    fn unregister_removes_strategy() {
260        let mut registry = TamperRegistry::with_defaults();
261        assert!(registry.get("url_encode").is_some());
262        let removed = registry.unregister("url_encode");
263        assert!(removed.is_some());
264        assert!(registry.get("url_encode").is_none());
265    }
266
267    #[test]
268    fn clear_removes_all() {
269        let mut registry = TamperRegistry::with_defaults();
270        registry.clear();
271        assert!(registry.names().is_empty());
272    }
273
274    #[test]
275    fn nan_aggressiveness_treated_as_one() {
276        struct NaNStrategy;
277        impl TamperStrategy for NaNStrategy {
278            fn name(&self) -> &'static str {
279                "nan_test"
280            }
281            fn description(&self) -> &'static str {
282                "test"
283            }
284            fn tamper(&self, _p: &str, _c: Option<&str>) -> String {
285                "test".to_string()
286            }
287            fn aggressiveness(&self) -> f64 {
288                f64::NAN
289            }
290        }
291        let mut registry = TamperRegistry::new();
292        registry.register(Box::new(NaNStrategy));
293        let sorted = registry.by_aggressiveness();
294        assert_eq!(sorted.len(), 1);
295    }
296
297    #[test]
298    fn all_tamper_names_static() {
299        let names = all_tamper_names();
300        assert!(!names.is_empty());
301        assert!(names.contains(&"url_encode"));
302    }
303
304    #[test]
305    fn tamper_error_display() {
306        let err = TamperError::StrategyNotFound("test".to_string());
307        assert_eq!(format!("{err}"), "Strategy not found: test");
308    }
309
310    #[test]
311    fn default_registry_function() {
312        let registry = default_registry();
313        assert!(!registry.names().is_empty());
314    }
315
316    #[test]
317    fn convenience_tamper_function() {
318        let result = tamper("url_encode", "test!", None);
319        assert!(result.is_ok());
320        assert_eq!(result.unwrap(), "test%21");
321    }
322
323    #[test]
324    fn convenience_tamper_function_error() {
325        let result = tamper("unknown", "test", None);
326        assert!(result.is_err());
327    }
328}