waf-detection 0.5.3

Detection modules (SQLi, XSS, RCE, traversal, SSRF, XXE, and more) and the scope-aware fast-path prefilter for Light WAF.
Documentation
// SPDX-FileCopyrightText: 2026 0x00spor3
// SPDX-License-Identifier: Apache-2.0

use regex::RegexSet;
use tracing::warn;
use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};

use crate::{all_matches, body_str_values, inspectable_header_values, Rule};

// ── rules ─────────────────────────────────────────────────────────────────────

pub static SQLI_RULES: &[Rule] = &[
    Rule {
        id: "sqli-union-select",
        pattern: r"(?i)\bunion\s+(?:all\s+)?select\b",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-tautology-or",
        // OR <operand> <op> <operand> boolean tautology. The regex engine has no
        // backreferences, so we can't enforce equality — we instead constrain the shape:
        //   • `=` form: each operand is a single (optionally quoted) char or a numeric
        //     literal (int/float/scientific/hex): `OR 1=1`, `OR 'a'='a'`, `OR x=x`,
        //     `OR 1.0e1=1e1`, `OR 0x1=0x1`. The single-char operand keeps `'a'='a'`.
        //   • comparison (`<` `>` `<>` `!=` `<=` `>=`) and `LIKE` forms: operands are
        //     restricted to NUMERIC literals (E-2), which catches `OR 1<2` / `OR 1 LIKE 1`
        //     while keeping benign prose with single-char words clean (`a<b or c>d` does
        //     NOT fire, because `c`/`d` are not numeric).
        // Restricting operands to numeric/single-char also rejects benign `or word=word`
        // phrases (e.g. "men or women=adult") that the old space-in-class `+` pattern flagged.
        pattern: r#"(?i)\bor\s+(?:(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?|['"`]?\w['"`]?)\s*=\s*(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?|['"`]?\w['"`]?)|(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?)\s*(?:<=|>=|<>|!=|<|>)\s*(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?)|(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?)\s+like\s+(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?))"#,
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-stacked-query",
        // Semicolon followed by a DML/DDL keyword — stacked query injection.
        pattern: r"(?i);\s*(?:select|insert|update|delete|drop|truncate|exec(?:ute)?|call)\b",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-time-based",
        pattern: r"(?i)\b(?:sleep|pg_sleep|waitfor\s+delay|benchmark)\s*\(",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-mysql-versioned-comment",
        // MySQL executable comment `/*!...*/` (optionally version-gated `/*!50000`)
        // wrapping a SQL keyword — the classic `/*!UNiOn*/ /*!SeLEct*/` evasion that
        // splits keywords so `union\s+select` can't see them (Fase 10b, gotestwaf
        // sql-injection). Requiring a SQL keyword after `/*!` excludes the benign
        // CSS/JS minifier preservation comment `/*! license … */`.
        pattern: r"(?i)/\*!\d*\s*(?:union|select|insert|update|delete|drop|alter|or|and|where|from|having|exec|cast|concat|sleep)",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-information-schema",
        // Access to the DB metadata catalog — schema/table/column enumeration. The
        // underscore token `information_schema` never appears in benign input ("the
        // information schema of the form" has a space, not `_`). gotestwaf
        // sql-injection `… from information_schema.columns …`.
        pattern: r"(?i)\binformation_schema\b",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-json-function",
        // MySQL JSON functions used in boolean/exfil SQLi (`OR JSON_EXTRACT(…)=…`,
        // `AND JSON_DEPTH('{}')!=…`) — gotestwaf sql-injection. A `json_<fn>(` call in
        // a user value is an unequivocal SQL signal (no benign API passes it as data).
        pattern: r"(?i)\bjson_(?:extract|depth|keys|search|contains|contains_path|value|arrayagg|objectagg|object|array|quote|unquote|type|valid|length|merge\w*|set|insert|replace|remove|overlaps|table|pretty|storage_\w+)\s*\(",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-tautology-and",
        // Same shape/operands as sqli-tautology-or (E-2): `=` with single-char/numeric
        // operands, plus comparison/LIKE with numeric operands. Rejects benign
        // `and word=word` and keeps `red and blue=mix` clean.
        pattern: r#"(?i)\band\s+(?:(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?|['"`]?\w['"`]?)\s*=\s*(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?|['"`]?\w['"`]?)|(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?)\s*(?:<=|>=|<>|!=|<|>)\s*(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?)|(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?)\s+like\s+(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e\d+)?))"#,
        severity: Severity::Warning,
        paranoia: 2,
    },
    Rule {
        id: "sqli-quote-comment",
        // Single/double quote immediately followed by -- or # comment markers.
        pattern: r#"(?i)['"`]\s*(?:--|#)"#,
        severity: Severity::Warning,
        paranoia: 2,
    },
    Rule {
        id: "sqli-cast-convert",
        // CAST(x AS type) or CONVERT(x, type) — common in data exfiltration.
        pattern: r"(?i)\b(?:cast|convert)\s*\(",
        severity: Severity::Notice,
        paranoia: 3,
    },
    Rule {
        id: "sqli-hex-literal",
        // Long hex literals used to encode strings (≥6 hex digits to avoid
        // matching short colour codes like #fff or 0x1A).
        pattern: r"(?i)\b0x[0-9a-f]{6,}\b",
        severity: Severity::Notice,
        paranoia: 3,
    },
    Rule {
        id: "sqli-error-based-fn",
        // Error-based SQLi via the MySQL/MariaDB XPATH functions: the injected
        // `extractvalue(1,concat(0x7e,(SELECT …)))` / `updatexml(…)` forces the DB to
        // echo query results inside an "XPATH syntax error" message (DVWA pentest D-1,
        // confirmed exfiltration of the admin hash). These predicates carry no
        // UNION/OR/comment/tautology token, so the other rules miss them. A
        // `extractvalue(`/`updatexml(` call in request data is an unequivocal SQL signal;
        // prose that merely names the function (no attached `(`) stays clean.
        pattern: r"(?i)\b(?:extractvalue|updatexml)\s*\(",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-error-exp-overflow",
        // Error-based SQLi via arithmetic overflow: `exp(~(SELECT …))` overflows the
        // double and leaks the subquery in the error (D-1). The `~` (bitwise NOT) right
        // after `exp(` is the SQL-specific tell — a benign `exp(2)`/`exp(-1.5)` math call
        // has no `~`, so FP is near-zero.
        pattern: r"(?i)\bexp\s*\(\s*~",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-subquery-exists",
        // Boolean subquery `EXISTS(SELECT …)` used for blind inference (D-1). Requiring
        // `select` after the paren keeps benign prose ("check if the record exists (…)")
        // clean while catching the injected predicate.
        pattern: r"(?i)\bexists\s*\(\s*select\b",
        severity: Severity::Critical,
        paranoia: 1,
    },
    Rule {
        id: "sqli-mssql-dangerous-proc",
        // MSSQL extended/OLE-automation stored procedures that grant OS-level command
        // execution, registry/file access or outbound HTTP (gotestwaf sql-injection:
        // `EXEC Master.dbo.xp_cmdshell @c`). The proc name is INVOCATION-anchored — preceded
        // by `.`/`;`/`(`/`=` (schema-qualified / stacked / called) or `EXEC[UTE] [schema.]` —
        // so an attack form matches but benign prose that merely NAMES the proc ("how to
        // disable xp_cmdshell") does NOT false-positive. The de-obf channels feed it the
        // decoded Base64Flat form too.
        pattern: r"(?i)(?:[.;(=]\s*|\bexec(?:ute)?\s+(?:[\w$]+\.)*)(?:xp_cmdshell|xp_dirtree|xp_fileexist|xp_reg(?:read|write|deletekey|deletevalue|enumvalues)|sp_oacreate|sp_oamethod|sp_makewebtask|xp_servicecontrol|xp_availablemedia)\b",
        severity: Severity::Critical,
        paranoia: 1,
    },
];

// ── SQL comment evasion (G-1) ───────────────────────────────────────────────────

/// Replace each CLOSED SQL block comment `/* … */` with a single space, so a comment
/// smuggled BETWEEN keywords no longer defeats the `\s+`-based signatures: SQLite (and
/// MySQL/Postgres) treat `/* */` as whitespace, so `UNION/**/SELECT` is valid SQL — but
/// `\bunion\s+select\b` cannot see it. Collapsing to a space (NOT deleting — `UNIONSELECT`
/// would still miss) restores `UNION SELECT`. Mirrors ModSecurity `t:replaceComments`.
///
/// Returns `None` when the value has no `/*` (the common case → no allocation). The
/// collapsed copy is scanned ALONGSIDE the original (never instead of it), so the
/// dedicated `sqli-mysql-versioned-comment` rule still fires on the original `/*!…*/`
/// (which the collapse would otherwise erase). v1 handles block comments only; line
/// comments `--`/`#` terminate the line (they cannot separate keywords on one line) and
/// are far more false-positive-prone in benign text — deferred.
///
/// NOTE (boundary): this runs only in the native `sqli` module. Operator-authored CRS
/// `SecRule`s (the imported-rules engine) do NOT get this transform — a `t:replaceComments`
/// CRS transform is a documented follow-on (see BOUNDARY / ARCHITECTURE §6).
pub(crate) fn collapse_sql_block_comments(s: &str) -> Option<String> {
    if !s.contains("/*") {
        return None;
    }
    let mut out = String::with_capacity(s.len());
    let mut rest = s;
    let mut changed = false;
    while let Some(start) = rest.find("/*") {
        match rest[start + 2..].find("*/") {
            Some(end_rel) => {
                out.push_str(&rest[..start]);
                out.push(' '); // the comment becomes a single token separator
                rest = &rest[start + 2 + end_rel + 2..];
                changed = true;
            }
            None => {
                // Unterminated `/*` comments out the remainder → not a token separator
                // (and invalid/inert SQL). Keep it verbatim.
                out.push_str(rest);
                rest = "";
                break;
            }
        }
    }
    out.push_str(rest);
    changed.then_some(out)
}

// ── module ────────────────────────────────────────────────────────────────────

#[derive(Default)]
pub struct SqliModule {
    rule_set: Option<RegexSet>,
    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
    active_rules: Vec<&'static Rule>,
}

impl SqliModule {
    pub fn new() -> Self {
        Self::default()
    }
}

impl WafModule for SqliModule {
    fn id(&self) -> &str {
        "sqli"
    }

    fn phase(&self) -> Phase {
        Phase::Body
    }

    fn init(&mut self, cfg: &Config) {
        let pl = cfg.waf.paranoia_level;
        self.active_rules = SQLI_RULES.iter().filter(|r| r.paranoia <= pl).collect();
        self.rule_set = Some(
            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
                .expect("SQLi rule compilation failed — check patterns at startup"),
        );
    }

    fn inspect(&self, ctx: &RequestContext) -> Decision {
        let Some(rule_set) = &self.rule_set else {
            return Decision::Allow;
        };

        let body_vals = body_str_values(&ctx.normalized.body);

        // G-1: build comment-collapsed copies of every scanned value so a `/* */` smuggled
        // between keywords (`UNION/**/SELECT`) is caught. Scanned ALONGSIDE the originals
        // (see `collapse_sql_block_comments`), so `sqli-mysql-versioned-comment` still fires
        // on the raw `/*!…*/`. Only values containing `/*` allocate. Declared early so it
        // outlives the borrowing iterators built below.
        let collapsed: Vec<String> = std::iter::once(ctx.normalized.path.as_str())
            .chain(ctx.normalized.query_params.iter().map(|(_, v)| v.as_str()))
            .chain(ctx.normalized.cookies.iter().map(|(_, v)| v.as_str()))
            .chain(body_vals.iter().map(String::as_str))
            .chain(ctx.normalized.derived_decoded.iter().map(String::as_str))
            .chain(inspectable_header_values(ctx))
            .filter_map(collapse_sql_block_comments)
            .collect();

        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
        let body = body_vals.iter().map(String::as_str);
        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);

        // URLPath coverage (10c REOPEN, pcap-confirmed): gotestwaf places payloads in the
        // URL PATH; this module must scan the resolved path too (mirrors rce/xss), else a
        // path-placed payload bypasses. The prefilter already scans the path (sound).
        let path = std::iter::once(ctx.normalized.path.as_str());
        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
        let headers = inspectable_header_values(ctx);

        let matched = all_matches(
            rule_set,
            path.chain(query)
                .chain(cookies)
                .chain(body)
                .chain(derived)
                .chain(headers)
                .chain(collapsed.iter().map(String::as_str)),
        );
        if matched.is_empty() {
            return Decision::Allow;
        }

        let items: Vec<ScoreItem> = matched
            .iter()
            .map(|&idx| {
                let rule = self.active_rules[idx];
                warn!(
                    request_id = %ctx.request_id,
                    rule_id = %rule.id,
                    severity = ?rule.severity,
                    "sqli detection"
                );
                ScoreItem {
                    rule_id: rule.id.to_string(),
                    severity: rule.severity,
                }
            })
            .collect();

        Decision::Scores(items)
    }
}