bext-waf 0.2.0

Web Application Firewall for bext — rate limiting, IP filtering, GeoIP, rule engine
Documentation
//! SQL injection detection — UNION SELECT, boolean tautologies, DROP/ALTER,
//! stacked queries, comment-based bypass, and hex/char obfuscation patterns.

use std::sync::OnceLock;

use regex::RegexSet;

static SQLI_PATTERNS: OnceLock<RegexSet> = OnceLock::new();

/// Descriptions matching each pattern by index.
static SQLI_DESCRIPTIONS: &[&str] = &[
    "UNION SELECT injection",
    "OR boolean tautology (1=1)",
    "AND boolean tautology (1=1)",
    "SQL comment injection (--)",
    "SQL comment injection (/*)",
    "DROP TABLE statement",
    "DELETE FROM statement",
    "INSERT INTO statement",
    "UPDATE SET statement",
    "String termination with SQL keyword",
    "Hex-encoded SQL keyword",
    "BENCHMARK blind SQLi",
    "SLEEP blind SQLi",
    "WAITFOR DELAY blind SQLi",
    "EXEC/EXECUTE statement",
    "LOAD_FILE / INTO OUTFILE",
    "CONCAT/CHAR function injection",
    "HAVING/GROUP BY injection",
    "OR empty-string tautology (OR ''='')",
];

fn patterns() -> &'static RegexSet {
    SQLI_PATTERNS.get_or_init(|| {
        RegexSet::new([
            // 0: UNION SELECT
            r"(?i)union[\s(]*(all[\s(]+)?select[\s(]",
            // 1: OR 1=1 / OR 'a'='a' / OR true
            r#"(?i)\bor\s+['"]?\w+['"]?\s*=\s*['"]?\w+['"]?"#,
            // 2: AND 1=1 / AND 'a'='a'
            r#"(?i)\band\s+[\d'"]+\s*=\s*[\d'"]+"#,
            // 3: comment injection (--)
            r#"(?i)['"]\s*;\s*--"#,
            // 4: comment injection (/*)
            r"/\*.*?\*/",
            // 5: DROP TABLE / DROP DATABASE
            r"(?i)\bdrop\s+(table|database|index)\b",
            // 6: DELETE FROM
            r"(?i)\bdelete\s+from\b",
            // 7: INSERT INTO
            r"(?i)\binsert\s+into\b",
            // 8: UPDATE ... SET
            r"(?i)\bupdate\b.+?\bset\b",
            // 9: String termination with SQL
            r#"(?i)['"];\s*(select|insert|update|delete|drop|alter|create|exec)\b"#,
            // 10: Hex-encoded
            r"(?i)0x[0-9a-f]{8,}",
            // 11: BENCHMARK
            r"(?i)\bbenchmark\s*\(",
            // 12: SLEEP
            r"(?i)\bsleep\s*\(",
            // 13: WAITFOR DELAY
            r"(?i)\bwaitfor\s+delay\b",
            // 14: EXEC / EXECUTE
            r"(?i)\bexec(ute)?\s*\(",
            // 15: LOAD_FILE / INTO OUTFILE
            r"(?i)\b(load_file|into\s+outfile|into\s+dumpfile)\b",
            // 16: CONCAT/CHAR function
            r"(?i)\b(concat|char|ascii|substring|mid)\s*\(",
            // 17: HAVING / GROUP BY
            r"(?i)\b(having|group\s+by)\s+.+?(=|<|>|like)",
            // 18: OR empty-string tautology
            r#"(?i)\bor\s+['"]{2}\s*=\s*['"]{1}"#,
        ])
        .expect("SQLI regex patterns must compile")
    })
}

/// Check an input string for SQL injection patterns.
/// Returns `Some(description)` if a pattern matches.
pub fn check_sqli(input: &str) -> Option<String> {
    let set = patterns();
    let matches: Vec<_> = set.matches(input).into_iter().collect();
    if matches.is_empty() {
        None
    } else {
        // Return the first matched pattern description.
        let idx = matches[0];
        Some(
            SQLI_DESCRIPTIONS
                .get(idx)
                .unwrap_or(&"SQL injection")
                .to_string(),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ---- Positive detections ----

    #[test]
    fn detects_union_select() {
        assert!(check_sqli("1 UNION SELECT * FROM users").is_some());
        assert!(check_sqli("1 union all select username, password from users").is_some());
    }

    #[test]
    fn detects_or_tautology() {
        assert!(check_sqli("admin' OR 1=1 --").is_some());
        assert!(check_sqli("admin' or '1'='1").is_some());
    }

    #[test]
    fn detects_and_tautology() {
        assert!(check_sqli("1 AND 1=1").is_some());
    }

    #[test]
    fn detects_comment_injection() {
        assert!(check_sqli("admin'; -- comment").is_some());
        assert!(check_sqli("foo /* comment */ bar").is_some());
    }

    #[test]
    fn detects_drop_table() {
        assert!(check_sqli("'; DROP TABLE users; --").is_some());
        assert!(check_sqli("DROP DATABASE production").is_some());
    }

    #[test]
    fn detects_delete_from() {
        assert!(check_sqli("DELETE FROM users WHERE 1=1").is_some());
    }

    #[test]
    fn detects_insert_into() {
        assert!(check_sqli("INSERT INTO users(name) VALUES('hacker')").is_some());
    }

    #[test]
    fn detects_update_set() {
        assert!(check_sqli("UPDATE users SET password='hacked'").is_some());
    }

    #[test]
    fn detects_string_termination_with_sql() {
        assert!(check_sqli("'; select * from users").is_some());
        assert!(check_sqli("\"; drop table users").is_some());
    }

    #[test]
    fn detects_hex_encoding() {
        assert!(check_sqli("0x61646d696e20").is_some());
    }

    #[test]
    fn detects_benchmark_sleep() {
        assert!(check_sqli("1 AND BENCHMARK(10000000,SHA1('test'))").is_some());
        assert!(check_sqli("1 OR SLEEP(5)").is_some());
    }

    #[test]
    fn detects_waitfor_delay() {
        assert!(check_sqli("'; WAITFOR DELAY '0:0:5'--").is_some());
    }

    #[test]
    fn detects_exec_execute() {
        assert!(check_sqli("EXEC('SELECT 1')").is_some());
        assert!(check_sqli("execute('malicious')").is_some());
    }

    #[test]
    fn detects_load_file() {
        assert!(check_sqli("LOAD_FILE('/etc/passwd')").is_some());
        assert!(check_sqli("INTO OUTFILE '/tmp/data'").is_some());
    }

    #[test]
    fn detects_concat_char() {
        assert!(check_sqli("CONCAT(0x7e, version(), 0x7e)").is_some());
        assert!(check_sqli("CHAR(97,100,109,105,110)").is_some());
    }

    // ---- False-positive checks ----

    #[test]
    fn allows_normal_search_query() {
        assert!(check_sqli("best restaurants near me").is_none());
    }

    #[test]
    fn allows_normal_product_description() {
        assert!(check_sqli("This product is great and has a 5-star rating").is_none());
    }

    #[test]
    fn allows_normal_url_path() {
        assert!(check_sqli("/api/users/123/profile").is_none());
    }

    #[test]
    fn allows_json_payload() {
        assert!(check_sqli(r#"{"name":"John","age":30}"#).is_none());
    }

    #[test]
    fn allows_normal_text_with_quotes() {
        assert!(check_sqli("It's a wonderful day").is_none());
    }
}