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};
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",
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",
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",
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",
pattern: r"(?i)\binformation_schema\b",
severity: Severity::Critical,
paranoia: 1,
},
Rule {
id: "sqli-json-function",
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",
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",
pattern: r#"(?i)['"`]\s*(?:--|#)"#,
severity: Severity::Warning,
paranoia: 2,
},
Rule {
id: "sqli-cast-convert",
pattern: r"(?i)\b(?:cast|convert)\s*\(",
severity: Severity::Notice,
paranoia: 3,
},
Rule {
id: "sqli-hex-literal",
pattern: r"(?i)\b0x[0-9a-f]{6,}\b",
severity: Severity::Notice,
paranoia: 3,
},
Rule {
id: "sqli-error-based-fn",
pattern: r"(?i)\b(?:extractvalue|updatexml)\s*\(",
severity: Severity::Critical,
paranoia: 1,
},
Rule {
id: "sqli-error-exp-overflow",
pattern: r"(?i)\bexp\s*\(\s*~",
severity: Severity::Critical,
paranoia: 1,
},
Rule {
id: "sqli-subquery-exists",
pattern: r"(?i)\bexists\s*\(\s*select\b",
severity: Severity::Critical,
paranoia: 1,
},
Rule {
id: "sqli-mssql-dangerous-proc",
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,
},
];
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(' '); rest = &rest[start + 2 + end_rel + 2..];
changed = true;
}
None => {
out.push_str(rest);
rest = "";
break;
}
}
}
out.push_str(rest);
changed.then_some(out)
}
#[derive(Default)]
pub struct SqliModule {
rule_set: Option<RegexSet>,
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);
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);
let path = std::iter::once(ctx.normalized.path.as_str());
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)
}
}