Skip to main content

wafrift_encoding/encoding/
layered.rs

1//! Multi-strategy encoding chains and aggressiveness scoring.
2
3use super::strategy::{Strategy, all_strategies, encode};
4use crate::error::EncodeError;
5
6/// Maximum accumulated output size for layered encoding.
7pub const MAX_LAYERED_OUTPUT_SIZE: usize = 8 * 1024 * 1024;
8
9/// Apply multiple encoding strategies in sequence (layered encoding).
10///
11/// # Errors
12/// Returns `EncodeError::PayloadTooLarge` if the input exceeds [`super::strategy::MAX_PAYLOAD_SIZE`].
13/// Returns `EncodeError::LayeredOutputTooLarge` if any intermediate output
14/// exceeds [`MAX_LAYERED_OUTPUT_SIZE`].
15pub fn encode_layered(
16    payload: impl AsRef<[u8]>,
17    strategies: &[Strategy],
18) -> Result<String, EncodeError> {
19    let payload = payload.as_ref();
20    let mut result = encode(
21        payload,
22        strategies.first().copied().unwrap_or(Strategy::UrlEncode),
23    )?;
24
25    for strategy in strategies.iter().skip(1) {
26        if result.len() > MAX_LAYERED_OUTPUT_SIZE {
27            return Err(EncodeError::LayeredOutputTooLarge {
28                max: MAX_LAYERED_OUTPUT_SIZE,
29                actual: result.len(),
30            });
31        }
32        result = encode(&result, *strategy)?;
33    }
34
35    if result.len() > MAX_LAYERED_OUTPUT_SIZE {
36        return Err(EncodeError::LayeredOutputTooLarge {
37            max: MAX_LAYERED_OUTPUT_SIZE,
38            actual: result.len(),
39        });
40    }
41
42    Ok(result)
43}
44
45/// Generate programmatic combinations up to a depth limit.
46///
47/// Filters out redundant pairings (same strategy twice, or pairings that
48/// produce semantically equivalent outputs).
49pub fn layered_combinations(depth: usize) -> Vec<Vec<Strategy>> {
50    let base = all_strategies();
51    let mut results: Vec<Vec<Strategy>> = Vec::new();
52
53    fn backtrack(
54        base: &[Strategy],
55        current: &mut Vec<Strategy>,
56        results: &mut Vec<Vec<Strategy>>,
57        depth: usize,
58    ) {
59        if current.len() >= 2 && current.len() <= depth {
60            results.push(current.clone());
61        }
62        if current.len() >= depth {
63            return;
64        }
65        for s in base {
66            // Skip redundant consecutive duplicates
67            if current.last() == Some(s) {
68                continue;
69            }
70            // Skip some known-redundant pairings
71            if let Some(last) = current.last()
72                && redundant_pair(*last, *s)
73            {
74                continue;
75            }
76            current.push(*s);
77            backtrack(base, current, results, depth);
78            current.pop();
79        }
80    }
81
82    let mut current = Vec::new();
83    backtrack(base, &mut current, &mut results, depth);
84    results
85}
86
87fn redundant_pair(a: Strategy, b: Strategy) -> bool {
88    // URL + URL variants are redundant with existing single strategies
89    matches!(
90        (a, b),
91        (Strategy::UrlEncode, Strategy::UrlEncode)
92            | (Strategy::UrlEncode, Strategy::UrlEncodeLower)
93            | (Strategy::UrlEncodeLower, Strategy::UrlEncode)
94            | (Strategy::UrlEncodeLower, Strategy::UrlEncodeLower)
95            | (Strategy::DoubleUrlEncode, Strategy::UrlEncode)
96            | (Strategy::TripleUrlEncode, Strategy::UrlEncode)
97            | (Strategy::CaseAlternation, Strategy::RandomCase)
98            | (Strategy::RandomCase, Strategy::CaseAlternation)
99    )
100}
101
102/// Estimate how aggressive an encoding strategy is (0.0 = mild, 1.0 = extreme).
103///
104/// Used by the strategy engine to decide escalation order.
105#[must_use]
106pub fn aggressiveness(strategy: Strategy) -> f64 {
107    match strategy {
108        Strategy::CaseAlternation => 0.05,
109        Strategy::RandomCase => 0.08,
110        Strategy::UrlEncode => 0.1,
111        Strategy::UrlEncodeLower => 0.1,
112        Strategy::WhitespaceInsertion => 0.12,
113        Strategy::SqlCommentInsertion => 0.12,
114        Strategy::SpaceToPlus => 0.13,
115        Strategy::SpaceToRandomBlank => 0.14,
116        Strategy::SpaceToComment => 0.15,
117        Strategy::SpaceToDash => 0.15,
118        Strategy::SpaceToHash => 0.15,
119        Strategy::HtmlEntityEncode => 0.2,
120        Strategy::HtmlEntityDecimalEncode => 0.2,
121        Strategy::DoubleUrlEncode => 0.25,
122        Strategy::UnicodeEncode => 0.3,
123        Strategy::IisUnicodeEncode => 0.3,
124        Strategy::JsonEncode => 0.3,
125        Strategy::NullByte => 0.35,
126        Strategy::FullwidthEncode => 0.36,
127        Strategy::HomoglyphEncode => 0.37,
128        Strategy::PercentagePrefix => 0.4,
129        Strategy::ParameterPollution => 0.45,
130        Strategy::TripleUrlEncode => 0.5,
131        Strategy::MysqlVersionedComment => 0.55,
132        Strategy::Base64Encode => 0.6,
133        Strategy::Base64UrlEncode => 0.6,
134        Strategy::OverlongUtf8 => 0.7,
135        Strategy::OverlongUtf8More => 0.75,
136        Strategy::HexEncode => 0.8,
137        Strategy::Utf7Encode => 0.85,
138        Strategy::BetweenObfuscation => 0.88,
139        Strategy::UnmagicQuotes => 0.9,
140        Strategy::ChunkedSplit => 0.92,
141        Strategy::GzipEncode => 0.95,
142        Strategy::DeflateEncode => 0.95,
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::encoding::strategy::all_strategies;
150
151    #[test]
152    fn encode_layered_basic() {
153        let result =
154            encode_layered("A", &[Strategy::UrlEncode, Strategy::DoubleUrlEncode]).unwrap();
155        assert!(result.contains('%'));
156    }
157
158    #[test]
159    fn encode_layered_size_limit() {
160        // Use a non-unreserved char so URL encoding multiplies size by ~3x each pass
161        let big = "!".repeat(5 * 1024 * 1024);
162        let result = encode_layered(
163            &big,
164            &[
165                Strategy::UrlEncode,
166                Strategy::UrlEncode,
167                Strategy::UrlEncode,
168            ],
169        );
170        assert!(matches!(
171            result,
172            Err(EncodeError::LayeredOutputTooLarge { .. })
173        ));
174    }
175
176    #[test]
177    fn layered_combinations_depth_2() {
178        let combos = layered_combinations(2);
179        assert!(!combos.is_empty());
180        // All combos should have length 2
181        assert!(combos.iter().all(|c| c.len() == 2));
182    }
183
184    #[test]
185    fn layered_combinations_no_consecutive_duplicates() {
186        let combos = layered_combinations(3);
187        for combo in combos {
188            for window in combo.windows(2) {
189                assert_ne!(window[0], window[1], "no consecutive duplicates: {combo:?}");
190            }
191        }
192    }
193
194    #[test]
195    fn aggressiveness_ordering() {
196        let strategies = all_strategies();
197        for i in 1..strategies.len() {
198            assert!(
199                aggressiveness(strategies[i - 1]) <= aggressiveness(strategies[i]),
200                "aggressiveness should be non-decreasing"
201            );
202        }
203    }
204
205    #[test]
206    fn encode_layered_empty_strategies() {
207        let result = encode_layered("hello", &[]).unwrap();
208        assert_eq!(result, "hello");
209    }
210
211    #[test]
212    fn encode_layered_single_strategy() {
213        let result = encode_layered("A<", &[Strategy::UrlEncode]).unwrap();
214        assert_eq!(result, "A%3C");
215    }
216
217    #[test]
218    fn layered_combinations_depth_1_returns_empty() {
219        let combos = layered_combinations(1);
220        assert!(combos.is_empty());
221    }
222
223    #[test]
224    fn aggressiveness_in_valid_range() {
225        for &s in all_strategies() {
226            let a = aggressiveness(s);
227            assert!(
228                (0.0..=1.0).contains(&a),
229                "aggressiveness for {s:?} out of range: {a}"
230            );
231        }
232    }
233}