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(|s| s.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(|s| s.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(|s| s.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)]
188pub enum TamperError {
189    /// The requested strategy was not found in the registry.
190    StrategyNotFound(String),
191    /// The TOML configuration is invalid.
192    InvalidConfig(String),
193    /// Failed to load strategies from file.
194    LoadError(String),
195}
196
197impl std::fmt::Display for TamperError {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        match self {
200            Self::StrategyNotFound(name) => write!(f, "Strategy not found: {name}"),
201            Self::InvalidConfig(msg) => write!(f, "Invalid configuration: {msg}"),
202            Self::LoadError(msg) => write!(f, "Failed to load strategies: {msg}"),
203        }
204    }
205}
206
207impl std::error::Error for TamperError {}
208
209/// Creates a registry with all default strategies.
210#[must_use]
211pub fn default_registry() -> TamperRegistry {
212    TamperRegistry::with_defaults()
213}
214
215/// Apply a single tamper strategy by name.
216///
217/// # Errors
218/// Returns an error if the strategy name is not recognized.
219pub fn tamper(strategy: &str, payload: &str, context: Option<&str>) -> Result<String, TamperError> {
220    let registry = default_registry();
221    registry.tamper_with(strategy, payload, context)
222}
223
224/// Get all available tamper strategy names.
225#[must_use]
226pub fn all_tamper_names() -> &'static [&'static str] {
227    DEFAULT_NAMES
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn registry_with_defaults_has_strategies() {
236        let registry = TamperRegistry::with_defaults();
237        assert!(!registry.names().is_empty());
238        assert!(registry.get("url_encode").is_some());
239        assert!(registry.get("base64").is_some());
240    }
241
242    #[test]
243    fn registry_lookup_fails_for_unknown() {
244        let registry = TamperRegistry::with_defaults();
245        assert!(registry.get("unknown_strategy").is_none());
246    }
247
248    #[test]
249    fn tamper_with_error_for_unknown() {
250        let registry = TamperRegistry::with_defaults();
251        let result = registry.tamper_with("unknown", "payload", None);
252        assert!(matches!(result, Err(TamperError::StrategyNotFound(_))));
253    }
254
255    #[test]
256    fn aggressiveness_sorting() {
257        let registry = TamperRegistry::with_defaults();
258        let strategies = registry.by_aggressiveness();
259        for i in 1..strategies.len() {
260            assert!(
261                strategies[i - 1].aggressiveness() <= strategies[i].aggressiveness(),
262                "Strategies should be sorted by aggressiveness"
263            );
264        }
265    }
266
267    #[test]
268    fn unregister_removes_strategy() {
269        let mut registry = TamperRegistry::with_defaults();
270        assert!(registry.get("url_encode").is_some());
271        let removed = registry.unregister("url_encode");
272        assert!(removed.is_some());
273        assert!(registry.get("url_encode").is_none());
274    }
275
276    #[test]
277    fn clear_removes_all() {
278        let mut registry = TamperRegistry::with_defaults();
279        registry.clear();
280        assert!(registry.names().is_empty());
281    }
282
283    #[test]
284    fn nan_aggressiveness_treated_as_one() {
285        struct NaNStrategy;
286        impl TamperStrategy for NaNStrategy {
287            fn name(&self) -> &'static str {
288                "nan_test"
289            }
290            fn description(&self) -> &'static str {
291                "test"
292            }
293            fn tamper(&self, _p: &str, _c: Option<&str>) -> String {
294                "test".to_string()
295            }
296            fn aggressiveness(&self) -> f64 {
297                f64::NAN
298            }
299        }
300        let mut registry = TamperRegistry::new();
301        registry.register(Box::new(NaNStrategy));
302        let sorted = registry.by_aggressiveness();
303        assert_eq!(sorted.len(), 1);
304    }
305
306    #[test]
307    fn all_tamper_names_static() {
308        let names = all_tamper_names();
309        assert!(!names.is_empty());
310        assert!(names.contains(&"url_encode"));
311    }
312
313    #[test]
314    fn tamper_error_display() {
315        let err = TamperError::StrategyNotFound("test".to_string());
316        assert_eq!(format!("{err}"), "Strategy not found: test");
317    }
318
319    #[test]
320    fn default_registry_function() {
321        let registry = default_registry();
322        assert!(!registry.names().is_empty());
323    }
324
325    #[test]
326    fn convenience_tamper_function() {
327        let result = tamper("url_encode", "test!", None);
328        assert!(result.is_ok());
329        assert_eq!(result.unwrap(), "test%21");
330    }
331
332    #[test]
333    fn convenience_tamper_function_error() {
334        let result = tamper("unknown", "test", None);
335        assert!(result.is_err());
336    }
337}