const XSS_SIGNATURES: &[&str] = &[
"<script",
"javascript:",
"onload=",
"onerror=",
"document.cookie",
];
const SQL_PROBES: &[&str] = &[
"sleep(",
"benchmark(",
"waitfor delay",
"pg_sleep(",
"information_schema",
"@@version",
"xp_cmdshell",
"load_file(",
"into outfile",
"into dumpfile",
];
const SHELL_COMMANDS: &[&str] = &[
"sh",
"bash",
"zsh",
"cat",
"ls",
"id",
"whoami",
"curl",
"wget",
"nc",
"ncat",
"ping",
"rm",
"python",
"perl",
"php",
"powershell",
"cmd",
];
const COMMAND_END: &[u8] = b" ;|&`)<>$'\"";
const SCHEMA_OBJECTS: &[&str] = &[
"table",
"database",
"schema",
"view",
"index",
"user",
"procedure",
"function",
"trigger",
];
const OPERATOR_WINDOW: usize = 32;
pub(super) fn is_injection(text: &str) -> bool {
let collapsed = collapse(text.as_bytes(), false);
if matches_signature(&collapsed) {
return true;
}
let stripped = collapse(text.as_bytes(), true);
stripped != collapsed && matches_signature(&stripped)
}
fn matches_signature(text: &[u8]) -> bool {
XSS_SIGNATURES
.iter()
.chain(SQL_PROBES)
.any(|signature| contains(text, signature.as_bytes()))
|| (0..text.len()).any(|at| structural_match(text, at))
}
fn structural_match(text: &[u8], at: usize) -> bool {
match text[at] {
b'\'' | b'"' => quote_breaks_out(text, at),
b';' => chains_statement(text, at + 1) || runs_command(text, at + 1),
b'|' | b'`' => runs_command(text, at + 1),
b'&' if text.get(at + 1) == Some(&b'&') => runs_command(text, at + 2),
b'$' if text.get(at + 1) == Some(&b'(') => runs_command(text, at + 2),
b'u' => (at == 0 || !is_word_byte(text[at - 1])) && unions_select(&text[at..]),
_ => false,
}
}
fn collapse(text: &[u8], strip_comments: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(text.len());
let mut unclosed_comment = false;
let mut at = 0;
while at < text.len() {
if strip_comments && !unclosed_comment && text[at..].starts_with(b"/*") {
match find(&text[at + 2..], b"*/") {
Some(end) => {
push_space(&mut out);
at += end + 4;
continue;
}
None => unclosed_comment = true,
}
}
if text[at].is_ascii_whitespace() {
push_space(&mut out);
} else {
out.push(text[at]);
}
at += 1;
}
out
}
fn push_space(out: &mut Vec<u8>) {
if out.last() != Some(&b' ') {
out.push(b' ');
}
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
find(haystack, needle).is_some()
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || !byte.is_ascii()
}
fn skip_spaces(text: &[u8], mut at: usize) -> usize {
while text.get(at) == Some(&b' ') {
at += 1;
}
at
}
fn word_at(text: &[u8], at: usize, word: &str) -> Option<usize> {
let end = at + word.len();
let rest = text.get(at..)?;
(rest.starts_with(word.as_bytes()) && !text.get(end).is_some_and(|b| is_word_byte(*b)))
.then_some(end)
}
fn then_any_word(text: &[u8], at: usize, words: &[&str]) -> Option<usize> {
let at = skip_spaces(text, at);
words.iter().find_map(|word| word_at(text, at, word))
}
fn identifier_end(text: &[u8], at: usize) -> Option<usize> {
let length = text
.get(at..)?
.iter()
.take_while(|byte| is_word_byte(**byte) || **byte == b'.')
.count();
(length > 0).then_some(at + length)
}
fn quote_breaks_out(text: &[u8], quote: usize) -> bool {
let mut at = quote + 1;
while matches!(text.get(at), Some(b' ' | b')')) {
at += 1;
}
let rest = &text[at.min(text.len())..];
if rest.starts_with(b"--") || rest.starts_with(b"/*") {
return true;
}
if rest.starts_with(b"#") {
return quote > 0 && is_word_byte(text[quote - 1]);
}
let operator = then_any_word(text, at, &["or", "and", "xor"])
.or_else(|| (rest.starts_with(b"||") || rest.starts_with(b"&&")).then_some(at + 2));
operator.is_some_and(|end| {
let window = &text[end..text.len().min(end + OPERATOR_WINDOW)];
window
.iter()
.any(|byte| matches!(byte, b'=' | b'<' | b'>' | b'#'))
|| contains(window, b"--")
|| contains(window, b"like")
})
}
fn chains_statement(text: &[u8], at: usize) -> bool {
let at = skip_spaces(text, at);
let verb = |word| word_at(text, at, word);
if let Some(end) = verb("drop")
.or_else(|| verb("create"))
.or_else(|| verb("alter"))
{
return then_any_word(text, end, SCHEMA_OBJECTS).is_some();
}
if let Some(end) = verb("truncate") {
return then_any_word(text, end, &["table"]).is_some();
}
if let Some(end) = verb("delete") {
return then_any_word(text, end, &["from"]).is_some();
}
if let Some(end) = verb("insert") {
return then_any_word(text, end, &["into"]).is_some();
}
if let Some(end) = verb("update") {
return identifier_end(text, skip_spaces(text, end))
.and_then(|table| then_any_word(text, table, &["set"]))
.is_some();
}
if let Some(end) = verb("select") {
return selects_columns(text, skip_spaces(text, end));
}
if let Some(end) = verb("declare") {
return text.get(skip_spaces(text, end)) == Some(&b'@');
}
verb("shutdown").is_some() || verb("exec").is_some()
}
fn selects_columns(text: &[u8], at: usize) -> bool {
match text.get(at) {
Some(b'*' | b'@' | b'(' | b'\'' | b'"') => return true,
Some(byte) if byte.is_ascii_digit() => return true,
_ => {}
}
let Some(mut end) = identifier_end(text, at) else {
return false;
};
loop {
let next = skip_spaces(text, end);
if text.get(next) != Some(&b',') {
break;
}
match identifier_end(text, skip_spaces(text, next + 1)) {
Some(column) => end = column,
None => return false,
}
}
then_any_word(text, end, &["from"]).is_some()
}
fn unions_select(text: &[u8]) -> bool {
let Some(mut at) = word_at(text, 0, "union") else {
return false;
};
let skip_open = |mut at: usize| {
while matches!(text.get(at), Some(b' ' | b'(')) {
at += 1;
}
at
};
at = skip_open(at);
if let Some(end) = word_at(text, at, "all").or_else(|| word_at(text, at, "distinct")) {
at = skip_open(end);
}
word_at(text, at, "select").is_some()
}
fn runs_command(text: &[u8], at: usize) -> bool {
let start = skip_spaces(text, at);
let Some(token) = text.get(start..) else {
return false;
};
let token_length = token
.iter()
.take_while(|byte| is_word_byte(**byte) || matches!(byte, b'/' | b'.' | b'-'))
.count();
let name = token[..token_length]
.iter()
.rposition(|byte| *byte == b'/')
.map_or(start, |slash| start + slash + 1);
SHELL_COMMANDS.iter().any(|command| {
if !text[name..].starts_with(command.as_bytes()) {
return false;
}
let mut end = name + command.len();
while text.get(end).is_some_and(u8::is_ascii_digit) {
end += 1;
}
text.get(end).is_none_or(|byte| COMMAND_END.contains(byte))
})
}