Skip to main content

rust_web_server/waf/
decode.rs

1//! Normalization before matching: what an attacker encodes, the WAF
2//! decodes. Every loop here is bounded by its input (fuzzed).
3
4/// The most of one value that is looked at.
5pub const MAX_VALUE: usize = 16 * 1024;
6
7/// Decode a value the way a browser, a server or a database might read it:
8/// percent-encoding (twice, for double encoding; `%uXXXX` too), HTML
9/// entities, `\xHH` / `\uXXXX` escapes; then NUL and control characters
10/// removed, SQL `/* comments */` turned into spaces, lower-cased, runs of
11/// whitespace collapsed.
12pub fn normalize(input: &str) -> String {
13    let mut s: String = input.chars().take(MAX_VALUE).collect();
14    for _ in 0..2 {
15        let next = percent_decode(&s);
16        if next == s {
17            break;
18        }
19        s = next;
20    }
21    s = html_entities(&s);
22    s = js_escapes(&s);
23    s = strip_sql_comments(&s);
24    let mut out = String::with_capacity(s.len());
25    let mut space = false;
26    for c in s.chars() {
27        let c = if c.is_whitespace() || c == '\u{0b}' || c == '\u{0c}' { ' ' } else { c };
28        if c.is_control() {
29            continue;
30        }
31        if c == ' ' {
32            if !space {
33                out.push(' ');
34            }
35            space = true;
36        } else {
37            out.extend(c.to_lowercase());
38            space = false;
39        }
40    }
41    out
42}
43
44fn hex(b: u8) -> Option<u32> {
45    (b as char).to_digit(16)
46}
47
48/// `%HH` and `%uHHHH`; `+` is left alone (query values were form-decoded).
49pub fn percent_decode(s: &str) -> String {
50    let b = s.as_bytes();
51    let mut out: Vec<u8> = Vec::with_capacity(b.len());
52    let mut i = 0;
53    while i < b.len() {
54        if b[i] == b'%' {
55            if matches!(b.get(i + 1), Some(b'u' | b'U')) {
56                if let (Some(a), Some(c), Some(d), Some(e)) = (b.get(i + 2).and_then(|x| hex(*x)), b.get(i + 3).and_then(|x| hex(*x)), b.get(i + 4).and_then(|x| hex(*x)), b.get(i + 5).and_then(|x| hex(*x))) {
57                    if let Some(ch) = char::from_u32(a << 12 | c << 8 | d << 4 | e) {
58                        let mut buf = [0u8; 4];
59                        out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
60                        i += 6;
61                        continue;
62                    }
63                }
64            }
65            if let (Some(h), Some(l)) = (b.get(i + 1).and_then(|x| hex(*x)), b.get(i + 2).and_then(|x| hex(*x))) {
66                out.push((h << 4 | l) as u8);
67                i += 3;
68                continue;
69            }
70        }
71        out.push(b[i]);
72        i += 1;
73    }
74    String::from_utf8_lossy(&out).into_owned()
75}
76
77/// Named entities that matter for markup and scripts, and numeric ones
78/// (`&#60;`, `&#x3c;`, with or without the `;`).
79pub fn html_entities(s: &str) -> String {
80    if !s.contains('&') {
81        return s.to_string();
82    }
83    const NAMED: [(&str, char); 14] = [
84        ("lt", '<'), ("gt", '>'), ("quot", '"'), ("apos", '\''), ("amp", '&'), ("colon", ':'), ("lpar", '('), ("rpar", ')'),
85        ("tab", '\t'), ("newline", '\n'), ("sol", '/'), ("bsol", '\\'), ("equals", '='), ("nbsp", ' '),
86    ];
87    let mut out = String::with_capacity(s.len());
88    let mut rest = s;
89    while let Some(at) = rest.find('&') {
90        out.push_str(&rest[..at]);
91        let after = &rest[at + 1..];
92        let mut consumed = None;
93        if let Some(num) = after.strip_prefix('#') {
94            let (digits, radix, skip) = match num.strip_prefix(['x', 'X']) {
95                Some(h) => (h.chars().take_while(|c| c.is_ascii_hexdigit()).take(8).collect::<String>(), 16, 2),
96                None => (num.chars().take_while(|c| c.is_ascii_digit()).take(8).collect::<String>(), 10, 1),
97            };
98            if !digits.is_empty() {
99                if let Some(ch) = u32::from_str_radix(&digits, radix).ok().and_then(char::from_u32) {
100                    out.push(ch);
101                    let mut len = skip + digits.len();
102                    if after[len..].starts_with(';') {
103                        len += 1;
104                    }
105                    consumed = Some(len);
106                }
107            }
108        } else {
109            let name: String = after.chars().take_while(|c| c.is_ascii_alphabetic()).take(10).collect();
110            if let Some((_, ch)) = NAMED.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)) {
111                out.push(*ch);
112                let mut len = name.len();
113                if after[len..].starts_with(';') {
114                    len += 1;
115                }
116                consumed = Some(len);
117            }
118        }
119        match consumed {
120            Some(len) => rest = &after[len..],
121            None => {
122                out.push('&');
123                rest = after;
124            }
125        }
126    }
127    out.push_str(rest);
128    out
129}
130
131/// `\xHH` and `\uHHHH`.
132pub fn js_escapes(s: &str) -> String {
133    if !s.contains('\\') {
134        return s.to_string();
135    }
136    let chars: Vec<char> = s.chars().collect();
137    let mut out = String::with_capacity(s.len());
138    let mut i = 0;
139    while i < chars.len() {
140        if chars[i] == '\\' && i + 1 < chars.len() {
141            let take = match chars[i + 1] {
142                'x' | 'X' => 2,
143                'u' | 'U' => 4,
144                _ => 0,
145            };
146            if take > 0 && i + 2 + take <= chars.len() {
147                let digits: String = chars[i + 2..i + 2 + take].iter().collect();
148                if let Some(ch) = u32::from_str_radix(&digits, 16).ok().and_then(char::from_u32) {
149                    out.push(ch);
150                    i += 2 + take;
151                    continue;
152                }
153            }
154        }
155        out.push(chars[i]);
156        i += 1;
157    }
158    out
159}
160
161/// `/* … */` (and MySQL's `/*! … */`) become a space: `un/**/ion` → `un ion`
162/// stays harmless, `union/**/select` → `union select` is caught.
163pub fn strip_sql_comments(s: &str) -> String {
164    if !s.contains("/*") {
165        return s.to_string();
166    }
167    let mut out = String::with_capacity(s.len());
168    let mut rest = s;
169    while let Some(start) = rest.find("/*") {
170        out.push_str(&rest[..start]);
171        out.push(' ');
172        match rest[start + 2..].find("*/") {
173            Some(end) => rest = &rest[start + 2 + end + 2..],
174            None => {
175                rest = "";
176            }
177        }
178    }
179    out.push_str(rest);
180    out
181}