Skip to main content

zentinel_modsec/parser/
variable.rs

1//! Variable parsing for SecRule.
2//!
3//! Optimized with perfect hash function for O(1) variable name lookup.
4
5use crate::error::{Error, Result};
6use phf::phf_map;
7
8/// A variable specification in a SecRule.
9#[derive(Debug, Clone)]
10pub struct VariableSpec {
11    /// The variable name.
12    pub name: VariableName,
13    /// Optional selection (e.g., ARGS:foo or ARGS:/^user/).
14    pub selection: Option<Selection>,
15    /// Count mode (& prefix).
16    pub count_mode: bool,
17    /// Exclusions (e.g., !ARGS:foo).
18    pub exclusions: Vec<String>,
19}
20
21/// Selection mode for collection variables.
22#[derive(Debug, Clone)]
23pub enum Selection {
24    /// Static key selection (ARGS:foo).
25    Key(String),
26    /// Regex key selection (ARGS:/^user/).
27    Regex(String),
28}
29
30/// Variable names supported by ModSecurity.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum VariableName {
33    // Request variables
34    Args, ArgsGet, ArgsPost, ArgsNames, ArgsGetNames, ArgsPostNames, ArgsCombinedSize,
35    RequestUri, RequestUriRaw, RequestFilename, RequestBasename, RequestLine,
36    RequestMethod, RequestProtocol, RequestHeaders, RequestHeadersNames,
37    RequestCookies, RequestCookiesNames, RequestBody, RequestBodyLength, QueryString,
38
39    // Response variables
40    ResponseStatus, ResponseProtocol, ResponseHeaders, ResponseHeadersNames,
41    ResponseBody, ResponseContentType, ResponseContentLength,
42
43    // Server/Client info
44    RemoteAddr, RemotePort, RemoteHost, RemoteUser,
45    ServerAddr, ServerPort, ServerName,
46
47    // Collections
48    Tx, Session, Env, Ip, Global, Resource, User, Geo,
49
50    // Matched data
51    MatchedVar, MatchedVars, MatchedVarName, MatchedVarsNames,
52
53    // Time variables
54    Time, TimeEpoch, TimeDay, TimeHour, TimeMin, TimeSec, TimeWday, TimeMon, TimeYear,
55
56    // Files
57    Files, FilesSizes, FilesTmpnames, FilesCombinedSize, FilesNames,
58
59    // Special
60    UniqueId, InboundAnomalyScore, OutboundAnomalyScore, Duration,
61    MultipartBoundaryQuoted, MultipartBoundaryWhitespace, MultipartDataAfter,
62    MultipartDataBefore, MultipartFileLimitExceeded, MultipartHeaderFolding,
63    MultipartInvalidHeaderFolding, MultipartInvalidPart, MultipartInvalidQuoting,
64    MultipartLfLine, MultipartMissingSemicolon, MultipartStrictError,
65    MultipartUnmatchedBoundary, MultipartPartHeaders,
66
67    // XML
68    Xml,
69
70    // Web server
71    WebserverErrorLog, HighestSeverity, StatusLine, FullRequest, FullRequestLength,
72
73    // Auth
74    AuthType,
75
76    // Request body processing
77    ReqBodyProcessor, ReqBodyError, ReqBodyErrorMsg, ReqBodyProcessorError, ReqBodyProcessorErrorMsg,
78
79    // Multipart strict
80    MultipartStrictCheck,
81}
82
83/// Which part of a flattened XML body an `XML:` selector refers to.
84///
85/// ModSecurity selects XML through XPath. This engine has no XPath evaluator
86/// and flattens XML bodies into `ARGS` instead, but the two selectors the OWASP
87/// CRS actually writes -- `XML:/*` for element content and `XML://@*` for
88/// attributes -- map onto that flattening exactly, and between them account for
89/// every `XML:` target in the stock rule set. Resolving those two costs nothing
90/// at request time and needs no XPath engine.
91///
92/// Anything else is genuinely unsupported and is reported when the rules load,
93/// rather than resolving to nothing and leaving the rule silently dead.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum XmlTarget {
96    /// `XML:/*` -- element text.
97    Elements,
98    /// `XML://@*` -- attribute values.
99    Attributes,
100    /// A bare `XML` with no selector: everything extracted from the body.
101    All,
102}
103
104impl XmlTarget {
105    /// Interpret an `XML:` selector, or `None` if this engine cannot express it.
106    pub fn from_selection(selection: Option<&Selection>) -> Option<Self> {
107        match selection {
108            None => Some(XmlTarget::All),
109            Some(Selection::Key(sel)) => match sel.trim() {
110                "/*" => Some(XmlTarget::Elements),
111                "//@*" => Some(XmlTarget::Attributes),
112                _ => None,
113            },
114            // `XML:/foo/` parses as a regex selection because of the
115            // delimiters, but an XPath expression is not a key regex.
116            Some(Selection::Regex(_)) => None,
117        }
118    }
119}
120
121impl VariableName {
122    /// Whether the resolver can produce a value for this variable.
123    ///
124    /// A variable the parser accepts but the resolver has no arm for silently
125    /// resolves to nothing, which makes a rule targeting it dead. Callers use
126    /// this to say so at load time instead of leaving it to be discovered from
127    /// traffic. The match is deliberately exhaustive: a new variant will not
128    /// compile until it has been classified here.
129    pub fn is_implemented(&self) -> bool {
130        match self {
131            VariableName::Xml | VariableName::Args | VariableName::ArgsGet | VariableName::ArgsPost |
132            VariableName::ArgsNames | VariableName::ArgsGetNames | VariableName::ArgsPostNames |
133            VariableName::ArgsCombinedSize | VariableName::RequestUri | VariableName::RequestUriRaw |
134            VariableName::RequestFilename | VariableName::RequestBasename | VariableName::RequestLine |
135            VariableName::RequestMethod | VariableName::RequestProtocol | VariableName::RequestHeaders |
136            VariableName::RequestHeadersNames | VariableName::RequestCookies | VariableName::RequestCookiesNames |
137            VariableName::RequestBody | VariableName::RequestBodyLength | VariableName::QueryString |
138            VariableName::ResponseStatus | VariableName::ResponseHeaders | VariableName::ResponseBody |
139            VariableName::ResponseContentType | VariableName::RemoteAddr | VariableName::RemotePort |
140            VariableName::ServerAddr | VariableName::ServerPort | VariableName::ServerName |
141            VariableName::Tx | VariableName::MatchedVar | VariableName::MatchedVars |
142            VariableName::MatchedVarName | VariableName::MatchedVarsNames | VariableName::Files |
143            VariableName::FilesNames | VariableName::MultipartPartHeaders | VariableName::ReqBodyProcessor |
144            VariableName::ReqBodyError | VariableName::ReqBodyErrorMsg | VariableName::ReqBodyProcessorError |
145            VariableName::ReqBodyProcessorErrorMsg => true,
146
147            VariableName::ResponseProtocol | VariableName::ResponseHeadersNames | VariableName::ResponseContentLength |
148            VariableName::RemoteHost | VariableName::RemoteUser | VariableName::Session |
149            VariableName::Env | VariableName::Ip | VariableName::Global |
150            VariableName::Resource | VariableName::User | VariableName::Geo |
151            VariableName::Time | VariableName::TimeEpoch | VariableName::TimeDay |
152            VariableName::TimeHour | VariableName::TimeMin | VariableName::TimeSec |
153            VariableName::TimeWday | VariableName::TimeMon | VariableName::TimeYear |
154            VariableName::FilesSizes | VariableName::FilesTmpnames | VariableName::FilesCombinedSize |
155            VariableName::UniqueId | VariableName::InboundAnomalyScore | VariableName::OutboundAnomalyScore |
156            VariableName::Duration | VariableName::MultipartBoundaryQuoted | VariableName::MultipartBoundaryWhitespace |
157            VariableName::MultipartDataAfter | VariableName::MultipartDataBefore | VariableName::MultipartFileLimitExceeded |
158            VariableName::MultipartHeaderFolding | VariableName::MultipartInvalidHeaderFolding | VariableName::MultipartInvalidPart |
159            VariableName::MultipartInvalidQuoting | VariableName::MultipartLfLine | VariableName::MultipartMissingSemicolon |
160            VariableName::MultipartStrictError | VariableName::MultipartUnmatchedBoundary |
161            VariableName::WebserverErrorLog | VariableName::HighestSeverity | VariableName::StatusLine |
162            VariableName::FullRequest | VariableName::FullRequestLength | VariableName::AuthType |
163            VariableName::MultipartStrictCheck => false,
164        }
165    }
166}
167
168/// Perfect hash map for O(1) variable name lookup.
169static VARIABLE_MAP: phf::Map<&'static str, VariableName> = phf_map! {
170    "ARGS" => VariableName::Args,
171    "ARGS_GET" => VariableName::ArgsGet,
172    "ARGS_POST" => VariableName::ArgsPost,
173    "ARGS_NAMES" => VariableName::ArgsNames,
174    "ARGS_GET_NAMES" => VariableName::ArgsGetNames,
175    "ARGS_POST_NAMES" => VariableName::ArgsPostNames,
176    "ARGS_COMBINED_SIZE" => VariableName::ArgsCombinedSize,
177    "REQUEST_URI" => VariableName::RequestUri,
178    "REQUEST_URI_RAW" => VariableName::RequestUriRaw,
179    "REQUEST_FILENAME" => VariableName::RequestFilename,
180    "REQUEST_BASENAME" => VariableName::RequestBasename,
181    "REQUEST_LINE" => VariableName::RequestLine,
182    "REQUEST_METHOD" => VariableName::RequestMethod,
183    "REQUEST_PROTOCOL" => VariableName::RequestProtocol,
184    "REQUEST_HEADERS" => VariableName::RequestHeaders,
185    "REQUEST_HEADERS_NAMES" => VariableName::RequestHeadersNames,
186    "REQUEST_COOKIES" => VariableName::RequestCookies,
187    "REQUEST_COOKIES_NAMES" => VariableName::RequestCookiesNames,
188    "REQUEST_BODY" => VariableName::RequestBody,
189    "REQUEST_BODY_LENGTH" => VariableName::RequestBodyLength,
190    "QUERY_STRING" => VariableName::QueryString,
191    "RESPONSE_STATUS" => VariableName::ResponseStatus,
192    "RESPONSE_PROTOCOL" => VariableName::ResponseProtocol,
193    "RESPONSE_HEADERS" => VariableName::ResponseHeaders,
194    "RESPONSE_HEADERS_NAMES" => VariableName::ResponseHeadersNames,
195    "RESPONSE_BODY" => VariableName::ResponseBody,
196    "RESPONSE_CONTENT_TYPE" => VariableName::ResponseContentType,
197    "RESPONSE_CONTENT_LENGTH" => VariableName::ResponseContentLength,
198    "REMOTE_ADDR" => VariableName::RemoteAddr,
199    "REMOTE_PORT" => VariableName::RemotePort,
200    "REMOTE_HOST" => VariableName::RemoteHost,
201    "REMOTE_USER" => VariableName::RemoteUser,
202    "SERVER_ADDR" => VariableName::ServerAddr,
203    "SERVER_PORT" => VariableName::ServerPort,
204    "SERVER_NAME" => VariableName::ServerName,
205    "TX" => VariableName::Tx,
206    "SESSION" => VariableName::Session,
207    "ENV" => VariableName::Env,
208    "IP" => VariableName::Ip,
209    "GLOBAL" => VariableName::Global,
210    "RESOURCE" => VariableName::Resource,
211    "USER" => VariableName::User,
212    "GEO" => VariableName::Geo,
213    "MATCHED_VAR" => VariableName::MatchedVar,
214    "MATCHED_VARS" => VariableName::MatchedVars,
215    "MATCHED_VAR_NAME" => VariableName::MatchedVarName,
216    "MATCHED_VARS_NAMES" => VariableName::MatchedVarsNames,
217    "TIME" => VariableName::Time,
218    "TIME_EPOCH" => VariableName::TimeEpoch,
219    "TIME_DAY" => VariableName::TimeDay,
220    "TIME_HOUR" => VariableName::TimeHour,
221    "TIME_MIN" => VariableName::TimeMin,
222    "TIME_SEC" => VariableName::TimeSec,
223    "TIME_WDAY" => VariableName::TimeWday,
224    "TIME_MON" => VariableName::TimeMon,
225    "TIME_YEAR" => VariableName::TimeYear,
226    "FILES" => VariableName::Files,
227    "FILES_SIZES" => VariableName::FilesSizes,
228    "FILES_TMPNAMES" => VariableName::FilesTmpnames,
229    "FILES_COMBINED_SIZE" => VariableName::FilesCombinedSize,
230    "FILES_NAMES" => VariableName::FilesNames,
231    "UNIQUE_ID" => VariableName::UniqueId,
232    "DURATION" => VariableName::Duration,
233    "HIGHEST_SEVERITY" => VariableName::HighestSeverity,
234    "STATUS_LINE" => VariableName::StatusLine,
235    "FULL_REQUEST" => VariableName::FullRequest,
236    "FULL_REQUEST_LENGTH" => VariableName::FullRequestLength,
237    "AUTH_TYPE" => VariableName::AuthType,
238    "XML" => VariableName::Xml,
239    "REQBODY_PROCESSOR" => VariableName::ReqBodyProcessor,
240    "REQBODY_ERROR" => VariableName::ReqBodyError,
241    "REQBODY_ERROR_MSG" => VariableName::ReqBodyErrorMsg,
242    "REQBODY_PROCESSOR_ERROR" => VariableName::ReqBodyProcessorError,
243    "REQBODY_PROCESSOR_ERROR_MSG" => VariableName::ReqBodyProcessorErrorMsg,
244    "MULTIPART_STRICT_ERROR" => VariableName::MultipartStrictCheck,
245    "MULTIPART_PART_HEADERS" => VariableName::MultipartPartHeaders,
246};
247
248impl VariableName {
249    /// Parse a variable name from a string (O(1) lookup).
250    #[inline]
251    pub fn from_str(s: &str) -> Option<Self> {
252        // Fast path: check if already uppercase ASCII
253        if s.bytes().all(|b| b.is_ascii_uppercase() || b == b'_') {
254            return VARIABLE_MAP.get(s).copied();
255        }
256        // Slow path: need to uppercase
257        let mut buf = [0u8; 64];
258        let len = s.len().min(64);
259        for (i, b) in s.bytes().take(len).enumerate() {
260            buf[i] = b.to_ascii_uppercase();
261        }
262        let upper = std::str::from_utf8(&buf[..len]).ok()?;
263        VARIABLE_MAP.get(upper).copied()
264    }
265
266    /// Check if this variable is a collection.
267    #[inline]
268    pub fn is_collection(&self) -> bool {
269        matches!(
270            self,
271            Self::Args | Self::ArgsGet | Self::ArgsPost | Self::ArgsNames
272                | Self::RequestHeaders | Self::RequestHeadersNames
273                | Self::RequestCookies | Self::RequestCookiesNames
274                | Self::ResponseHeaders | Self::ResponseHeadersNames
275                | Self::Tx | Self::Session | Self::Env | Self::Ip
276                | Self::Global | Self::Resource | Self::User | Self::Geo
277                | Self::MatchedVars | Self::MatchedVarsNames
278                | Self::Files | Self::FilesSizes | Self::FilesTmpnames | Self::FilesNames
279                | Self::MultipartPartHeaders
280        )
281    }
282}
283
284/// Parse a variable specification string.
285#[inline]
286pub fn parse_variables(input: &str) -> Result<Vec<VariableSpec>> {
287    let mut variables = Vec::with_capacity(4);
288    let mut exclusions: Vec<String> = Vec::new();
289
290    // Split by | for OR conditions
291    for part in input.split('|') {
292        let part = part.trim();
293        if part.is_empty() {
294            continue;
295        }
296
297        // Handle exclusions (!VAR)
298        if part.starts_with('!') {
299            exclusions.push(part[1..].to_string());
300            continue;
301        }
302
303        let spec = parse_single_variable(part)?;
304        variables.push(spec);
305    }
306
307    // Apply exclusions to all variables
308    if !exclusions.is_empty() {
309        for var in &mut variables {
310            var.exclusions = exclusions.clone();
311        }
312    }
313
314    Ok(variables)
315}
316
317/// Parse a `SecRuleUpdateTargetById`-style target list.
318///
319/// Unlike [`parse_variables`], exclusions (`!TARGET`) are returned separately
320/// rather than being attached to the positive specs, because for a target
321/// update they must be applied to the *existing* variables of the rule being
322/// updated. Returns `(positive_specs, exclusion_strings)`.
323pub fn parse_update_targets(input: &str) -> Result<(Vec<VariableSpec>, Vec<String>)> {
324    let mut additions = Vec::new();
325    let mut exclusions = Vec::new();
326
327    for part in input.split('|') {
328        let part = part.trim();
329        if part.is_empty() {
330            continue;
331        }
332        if let Some(excl) = part.strip_prefix('!') {
333            // Validate the collection name so a typo'd exclusion is a load
334            // error rather than a silently dead exclusion.
335            let name_end = excl.find(':').unwrap_or(excl.len());
336            let name_str = &excl[..name_end];
337            if VariableName::from_str(name_str).is_none() {
338                return Err(Error::UnknownVariable {
339                    name: name_str.to_string(),
340                });
341            }
342            exclusions.push(excl.to_string());
343        } else {
344            additions.push(parse_single_variable(part)?);
345        }
346    }
347
348    Ok((additions, exclusions))
349}
350
351/// Parse a single variable specification.
352#[inline]
353pub(crate) fn parse_single_variable(input: &str) -> Result<VariableSpec> {
354    let input = input.trim();
355    let bytes = input.as_bytes();
356
357    // Check for count mode (& prefix)
358    let (count_mode, input) = if bytes.first() == Some(&b'&') {
359        (true, &input[1..])
360    } else {
361        (false, input)
362    };
363
364    // Find colon for selection (use memchr-style search)
365    let colon_pos = input.bytes().position(|b| b == b':');
366
367    let (name_str, selection) = match colon_pos {
368        Some(pos) => {
369            let name = &input[..pos];
370            let sel_str = &input[pos + 1..];
371
372            let selection = if sel_str.starts_with('/') && sel_str.ends_with('/') && sel_str.len() > 2 {
373                Some(Selection::Regex(sel_str[1..sel_str.len() - 1].to_string()))
374            } else {
375                Some(Selection::Key(sel_str.to_string()))
376            };
377
378            (name, selection)
379        }
380        None => (input, None),
381    };
382
383    let name = VariableName::from_str(name_str).ok_or_else(|| Error::UnknownVariable {
384        name: name_str.to_string(),
385    })?;
386
387    Ok(VariableSpec {
388        name,
389        selection,
390        count_mode,
391        exclusions: Vec::new(),
392    })
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_parse_simple_variable() {
401        let vars = parse_variables("REQUEST_URI").unwrap();
402        assert_eq!(vars.len(), 1);
403        assert_eq!(vars[0].name, VariableName::RequestUri);
404        assert!(vars[0].selection.is_none());
405        assert!(!vars[0].count_mode);
406    }
407
408    #[test]
409    fn test_parse_variable_with_selection() {
410        let vars = parse_variables("ARGS:username").unwrap();
411        assert_eq!(vars.len(), 1);
412        assert_eq!(vars[0].name, VariableName::Args);
413        assert!(matches!(&vars[0].selection, Some(Selection::Key(k)) if k == "username"));
414    }
415
416    #[test]
417    fn test_parse_variable_with_regex() {
418        let vars = parse_variables("ARGS:/^user/").unwrap();
419        assert_eq!(vars.len(), 1);
420        assert_eq!(vars[0].name, VariableName::Args);
421        assert!(matches!(&vars[0].selection, Some(Selection::Regex(r)) if r == "^user"));
422    }
423
424    #[test]
425    fn test_parse_multipart_part_headers() {
426        // Used by CRS REQUEST-922; must parse rather than erroring as unknown.
427        let vars = parse_variables("MULTIPART_PART_HEADERS").unwrap();
428        assert_eq!(vars.len(), 1);
429        assert_eq!(vars[0].name, VariableName::MultipartPartHeaders);
430    }
431
432    #[test]
433    fn test_parse_count_mode() {
434        let vars = parse_variables("&ARGS").unwrap();
435        assert_eq!(vars.len(), 1);
436        assert!(vars[0].count_mode);
437    }
438
439    #[test]
440    fn test_parse_multiple_variables() {
441        let vars = parse_variables("REQUEST_URI|ARGS|REQUEST_HEADERS").unwrap();
442        assert_eq!(vars.len(), 3);
443    }
444
445    #[test]
446    fn test_variable_lookup_case_insensitive() {
447        assert_eq!(VariableName::from_str("REQUEST_URI"), Some(VariableName::RequestUri));
448        assert_eq!(VariableName::from_str("request_uri"), Some(VariableName::RequestUri));
449        assert_eq!(VariableName::from_str("Request_Uri"), Some(VariableName::RequestUri));
450    }
451}