use crate::traits::PayloadOracle;
pub struct SstiOracle;
const DELIMITER_PAIRS: &[(&str, &str)] = &[
("{{", "}}"), ("${", "}"), ("<%", "%>"), ("#{", "}"), ("{%", "%}"), ("#set(", ")"), ("{", "}"), ];
#[derive(serde::Deserialize)]
struct SstiMarkerRules {
marker: Vec<SstiMarker>,
}
#[derive(serde::Deserialize)]
struct SstiMarker {
token: String,
}
fn introspection_markers() -> &'static [String] {
static CACHE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
let raw = include_str!("../rules/ssti/markers.toml");
let parsed: SstiMarkerRules =
toml::from_str(raw).expect("rules/ssti/markers.toml must parse");
parsed.marker.into_iter().map(|m| m.token).collect()
})
}
fn has_template_structure(payload: &str) -> bool {
for &(open, close) in DELIMITER_PAIRS {
let mut search_start = 0;
while let Some(start_pos) = payload[search_start..].find(open) {
let absolute_start = search_start + start_pos;
if open == "{" {
if absolute_start > 0 && payload.as_bytes()[absolute_start - 1] == b'{' {
search_start = absolute_start + 1;
continue;
}
if absolute_start == 0 && payload.starts_with("{{") {
search_start = 1;
continue;
}
}
let after_open = absolute_start + open.len();
if let Some(close_pos) = payload[after_open..].find(close)
&& close_pos > 0
{
if open == "{" && close == "}" {
let content = &payload[after_open..after_open + close_pos];
if looks_like_json(content) {
search_start = after_open;
continue;
}
}
return true;
}
search_start = after_open;
}
}
false
}
fn looks_like_json(content: &str) -> bool {
let trimmed = content.trim();
trimmed.starts_with('"')
|| trimmed.starts_with('\'')
|| trimmed.parse::<f64>().is_ok()
|| (trimmed.contains(':') && trimmed.contains('"'))
}
fn has_introspection(payload: &str) -> bool {
let lower = payload.to_ascii_lowercase();
introspection_markers()
.iter()
.any(|marker| lower.contains(&marker.to_ascii_lowercase()))
}
impl PayloadOracle for SstiOracle {
fn is_semantically_valid(&self, original: &str, transformed: &str) -> bool {
let original_has_structure = has_template_structure(original);
let transformed_has_structure = has_template_structure(transformed);
if original_has_structure && !transformed_has_structure {
return false;
}
let original_has_introspection = has_introspection(original);
if original_has_introspection && !has_introspection(transformed) {
return false;
}
transformed_has_structure
}
fn name(&self) -> &'static str {
"SSTI"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jinja2_expression_valid() {
let oracle = SstiOracle;
assert!(oracle.is_semantically_valid("{{7*7}}", "{{7*7}}"));
}
#[test]
fn jinja2_introspection_valid() {
let oracle = SstiOracle;
assert!(
oracle.is_semantically_valid("{{''.__class__.__mro__}}", "{{''.__class__.__mro__}}",)
);
}
#[test]
fn freemarker_expression_valid() {
let oracle = SstiOracle;
assert!(oracle.is_semantically_valid("${7*7}", "${7*7}"));
}
#[test]
fn erb_expression_valid() {
let oracle = SstiOracle;
assert!(oracle.is_semantically_valid("<%= 7*7 %>", "<%= 7*7 %>"));
}
#[test]
fn destroyed_delimiters_invalid() {
let oracle = SstiOracle;
assert!(!oracle.is_semantically_valid("{{7*7}}", "%7B%7B7*7%7D%7D",));
}
#[test]
fn introspection_destroyed_invalid() {
let oracle = SstiOracle;
assert!(
!oracle.is_semantically_valid("{{''.__class__.__mro__}}", "{{''.__c1ass__.__mr0__}}",)
);
}
#[test]
fn empty_delimiters_invalid() {
let oracle = SstiOracle;
assert!(!oracle.is_semantically_valid("{{7*7}}", "{{}}"));
}
#[test]
fn velocity_expression_valid() {
let oracle = SstiOracle;
assert!(oracle.is_semantically_valid("#set($x=7*7)", "#set($x=7*7)",));
}
#[test]
fn django_block_valid() {
let oracle = SstiOracle;
assert!(
oracle.is_semantically_valid("{% for x in range(10) %}", "{% for x in range(10) %}",)
);
}
#[test]
fn plain_text_invalid() {
let oracle = SstiOracle;
assert!(!oracle.is_semantically_valid("{{7*7}}", "hello world"));
}
#[test]
fn probe_with_different_expression_valid() {
let oracle = SstiOracle;
assert!(oracle.is_semantically_valid("{{7*7}}", "{{7*'7'}}"));
}
}