Skip to main content

waf_detection/
sqli.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use regex::RegexSet;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8use crate::{all_matches, body_str_values, inspectable_header_values, Rule};
9
10// ── rules ─────────────────────────────────────────────────────────────────────
11
12pub static SQLI_RULES: &[Rule] = &[
13    Rule {
14        id: "sqli-union-select",
15        pattern: r"(?i)\bunion\s+(?:all\s+)?select\b",
16        severity: Severity::Critical,
17        paranoia: 1,
18    },
19    Rule {
20        id: "sqli-tautology-or",
21        // OR <operand> = <operand> where each operand is numeric or a single
22        // (optionally quoted) char: OR 1=1, OR 'a'='a', OR x=x. The regex engine
23        // has no backreferences, so we can't enforce equality; restricting operands
24        // to numeric/single-char rejects benign `or word=word` phrases (e.g.
25        // "men or women=adult") that the old space-in-class `+` pattern flagged.
26        pattern: r#"(?i)\bor\s+(?:\d+|['"`]?\w['"`]?)\s*=\s*(?:\d+|['"`]?\w['"`]?)"#,
27        severity: Severity::Critical,
28        paranoia: 1,
29    },
30    Rule {
31        id: "sqli-stacked-query",
32        // Semicolon followed by a DML/DDL keyword — stacked query injection.
33        pattern: r"(?i);\s*(?:select|insert|update|delete|drop|truncate|exec(?:ute)?|call)\b",
34        severity: Severity::Critical,
35        paranoia: 1,
36    },
37    Rule {
38        id: "sqli-time-based",
39        pattern: r"(?i)\b(?:sleep|pg_sleep|waitfor\s+delay|benchmark)\s*\(",
40        severity: Severity::Critical,
41        paranoia: 1,
42    },
43    Rule {
44        id: "sqli-mysql-versioned-comment",
45        // MySQL executable comment `/*!...*/` (optionally version-gated `/*!50000`)
46        // wrapping a SQL keyword — the classic `/*!UNiOn*/ /*!SeLEct*/` evasion that
47        // splits keywords so `union\s+select` can't see them (Fase 10b, gotestwaf
48        // sql-injection). Requiring a SQL keyword after `/*!` excludes the benign
49        // CSS/JS minifier preservation comment `/*! license … */`.
50        pattern: r"(?i)/\*!\d*\s*(?:union|select|insert|update|delete|drop|alter|or|and|where|from|having|exec|cast|concat|sleep)",
51        severity: Severity::Critical,
52        paranoia: 1,
53    },
54    Rule {
55        id: "sqli-information-schema",
56        // Access to the DB metadata catalog — schema/table/column enumeration. The
57        // underscore token `information_schema` never appears in benign input ("the
58        // information schema of the form" has a space, not `_`). gotestwaf
59        // sql-injection `… from information_schema.columns …`.
60        pattern: r"(?i)\binformation_schema\b",
61        severity: Severity::Critical,
62        paranoia: 1,
63    },
64    Rule {
65        id: "sqli-json-function",
66        // MySQL JSON functions used in boolean/exfil SQLi (`OR JSON_EXTRACT(…)=…`,
67        // `AND JSON_DEPTH('{}')!=…`) — gotestwaf sql-injection. A `json_<fn>(` call in
68        // a user value is an unequivocal SQL signal (no benign API passes it as data).
69        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*\(",
70        severity: Severity::Critical,
71        paranoia: 1,
72    },
73    Rule {
74        id: "sqli-tautology-and",
75        // Same narrowing as sqli-tautology-or (rejects benign `and word=word`).
76        pattern: r#"(?i)\band\s+(?:\d+|['"`]?\w['"`]?)\s*=\s*(?:\d+|['"`]?\w['"`]?)"#,
77        severity: Severity::Warning,
78        paranoia: 2,
79    },
80    Rule {
81        id: "sqli-quote-comment",
82        // Single/double quote immediately followed by -- or # comment markers.
83        pattern: r#"(?i)['"`]\s*(?:--|#)"#,
84        severity: Severity::Warning,
85        paranoia: 2,
86    },
87    Rule {
88        id: "sqli-cast-convert",
89        // CAST(x AS type) or CONVERT(x, type) — common in data exfiltration.
90        pattern: r"(?i)\b(?:cast|convert)\s*\(",
91        severity: Severity::Notice,
92        paranoia: 3,
93    },
94    Rule {
95        id: "sqli-hex-literal",
96        // Long hex literals used to encode strings (≥6 hex digits to avoid
97        // matching short colour codes like #fff or 0x1A).
98        pattern: r"(?i)\b0x[0-9a-f]{6,}\b",
99        severity: Severity::Notice,
100        paranoia: 3,
101    },
102    Rule {
103        id: "sqli-mssql-dangerous-proc",
104        // MSSQL extended/OLE-automation stored procedures that grant OS-level command
105        // execution, registry/file access or outbound HTTP (gotestwaf sql-injection:
106        // `EXEC Master.dbo.xp_cmdshell @c`). The proc name is INVOCATION-anchored — preceded
107        // by `.`/`;`/`(`/`=` (schema-qualified / stacked / called) or `EXEC[UTE] [schema.]` —
108        // so an attack form matches but benign prose that merely NAMES the proc ("how to
109        // disable xp_cmdshell") does NOT false-positive. The de-obf channels feed it the
110        // decoded Base64Flat form too.
111        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",
112        severity: Severity::Critical,
113        paranoia: 1,
114    },
115];
116
117// ── module ────────────────────────────────────────────────────────────────────
118
119#[derive(Default)]
120pub struct SqliModule {
121    rule_set: Option<RegexSet>,
122    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
123    active_rules: Vec<&'static Rule>,
124}
125
126impl SqliModule {
127    pub fn new() -> Self {
128        Self::default()
129    }
130}
131
132impl WafModule for SqliModule {
133    fn id(&self) -> &str {
134        "sqli"
135    }
136
137    fn phase(&self) -> Phase {
138        Phase::Body
139    }
140
141    fn init(&mut self, cfg: &Config) {
142        let pl = cfg.waf.paranoia_level;
143        self.active_rules = SQLI_RULES.iter().filter(|r| r.paranoia <= pl).collect();
144        self.rule_set = Some(
145            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
146                .expect("SQLi rule compilation failed — check patterns at startup"),
147        );
148    }
149
150    fn inspect(&self, ctx: &RequestContext) -> Decision {
151        let Some(rule_set) = &self.rule_set else {
152            return Decision::Allow;
153        };
154
155        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
156        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
157        let body_vals = body_str_values(&ctx.normalized.body);
158        let body = body_vals.iter().map(String::as_str);
159        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
160
161        // URLPath coverage (10c REOPEN, pcap-confirmed): gotestwaf places payloads in the
162        // URL PATH; this module must scan the resolved path too (mirrors rce/xss), else a
163        // path-placed payload bypasses. The prefilter already scans the path (sound).
164        let path = std::iter::once(ctx.normalized.path.as_str());
165        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
166        let headers = inspectable_header_values(ctx);
167        let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
168        if matched.is_empty() {
169            return Decision::Allow;
170        }
171
172        let items: Vec<ScoreItem> = matched
173            .iter()
174            .map(|&idx| {
175                let rule = self.active_rules[idx];
176                warn!(
177                    request_id = %ctx.request_id,
178                    rule_id = %rule.id,
179                    severity = ?rule.severity,
180                    "sqli detection"
181                );
182                ScoreItem {
183                    rule_id: rule.id.to_string(),
184                    severity: rule.severity,
185                }
186            })
187            .collect();
188
189        Decision::Scores(items)
190    }
191}