use super::pyregex::{Candidate, PyRegex};
pub const LDAP_WILDCARD_CHAIN_RE: &str =
r"\*\)[|&]?\(+\s*(?::)?(?:[a-zA-Z][\w.-]*|\d+(?:\.\d+)*)(?:;[\w.-]+)*(?::[\w.-]+)*\s*:?=";
pub const LDAP_WILDCARD_EQUALS_RE: &str = r#"\*\s*\)+\s*(?:[|&!]\s*)?\(+\s*(?:[&|!]|(?::)?(?:[a-zA-Z][\w.-]*|\d+(?:\.\d+)*)(?:;[\w.-]+)*(?::[\w.-]+)*\s*:?=#";
pub const LDAP_PAREN_BREAKOUT_RE: &str = r#"\)\s*\(\s*(?:[&|!]|(?::)?(?:[a-zA-Z][\w.-]*|\d+(?:\.\d+)*)(?:;[\w.-]+)*(?::[\w.-]+)*\s*:?[=~<>])"#;
pub const LDAP_PAREN_CONJUNCTION_RE: &str = r"\(\s*[&|]\s*";
pub const LEGACY_IPV4_HOST_RE: &str = r"://(?:[^/@\s]*@)?((?:0[xX][0-9a-fA-F]+|0[0-7]+|[1-9]\d*|0)(?:\.(?:0[xX][0-9a-fA-F]+|0[0-7]+|[1-9]\d*|0)){0,3})(?=[:/\s]|$)";
#[must_use]
pub fn legacy_ipv4_finditer(haystack: &str) -> Vec<Candidate> {
let Ok(compiled) = PyRegex::compile(
r"://(?:[^/@\s]*@)?((?:0[xX][0-9a-fA-F]+|0[0-7]+|[1-9]\d*|0)(?:\.(?:0[xX][0-9a-fA-F]+|0[0-7]+|[1-9]\d*|0)){0,3})",
true,
) else {
return Vec::new();
};
let mut out = Vec::new();
for m in compiled.re().find_iter(haystack) {
let after = m.end();
let guard_ok = if after == haystack.len() {
true
} else if haystack[after..]
.chars()
.next()
.is_some_and(|c| c == '\n' && after + 1 == haystack.len())
{
true
} else {
haystack[after..]
.chars()
.next()
.is_some_and(|c| matches!(c, ':' | '/') || c.is_whitespace())
};
if guard_ok {
out.push(Candidate::new(m.start(), m.end()));
}
}
out
}
const BLOCKED_IPV4_RANGES: &[(u32, u32)] = &[
(0, 0x00ff_ffff), (0x7f00_0000, 0x7fff_ffff), (0x0a00_0000, 0x0aff_ffff), (0xac10_0000, 0xac1f_ffff), (0xc0a8_0000, 0xc0a8_ffff), (0xa9fe_0000, 0xa9fe_ffff), (0x6464_64c8, 0x6464_64c8), ];
fn decode_legacy_ipv4_part(part: &str) -> Option<u64> {
if let Some(digits) = part.strip_prefix("0x").or_else(|| part.strip_prefix("0X")) {
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
return u64::from_str_radix(digits, 16).ok();
}
if part.len() > 1 && part.starts_with('0') {
let digits = &part[1..];
if !digits.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
return None;
}
return u64::from_str_radix(digits, 8).ok();
}
if !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()) {
return part.parse::<u64>().ok();
}
None
}
fn is_bare_decimal(part: &str) -> bool {
part == "0" || !part.starts_with('0')
}
#[must_use]
pub fn decode_legacy_ipv4_host(host: &str) -> Option<u64> {
let parts: Vec<&str> = host.split('.').collect();
if parts.is_empty() || parts.len() > 4 {
return None;
}
let mut decoded = Vec::with_capacity(parts.len());
for part in &parts {
decoded.push(decode_legacy_ipv4_part(part)?);
}
if decoded.len() == 1 && decoded[0] != 0 && decoded[0] < (1 << 24) && is_bare_decimal(parts[0])
{
return None;
}
for value in &decoded[..decoded.len() - 1] {
if *value > 255 {
return None;
}
}
let remaining_bits = 8 * (5 - decoded.len());
let last = decoded[decoded.len() - 1];
if remaining_bits >= 64 || last >= (1u64 << remaining_bits) {
return None;
}
let mut result = 0u64;
for value in &decoded[..decoded.len() - 1] {
result = (result << 8) | value;
}
Some((result << remaining_bits) | last)
}
fn is_blocked_legacy_ipv4(ip: u64) -> bool {
BLOCKED_IPV4_RANGES
.iter()
.any(|(lo, hi)| ip >= u64::from(*lo) && ip <= u64::from(*hi))
}
#[must_use]
pub fn legacy_ipv4_match_is_blocked(candidate_text: &str) -> bool {
let Some(rest) = candidate_text.strip_prefix("://") else {
return false;
};
let host = rest.find('@').map_or(rest, |at| &rest[at + 1..]);
let Some(ip) = decode_legacy_ipv4_host(host) else {
return false;
};
is_blocked_legacy_ipv4(ip)
}
struct CharSpan {
chars: Vec<char>,
byte_at: Vec<usize>,
}
impl CharSpan {
fn new(text: &str) -> Self {
let chars: Vec<char> = text.chars().collect();
let mut byte_at = Vec::with_capacity(chars.len() + 1);
byte_at.push(0);
for (i, _) in text.char_indices().skip(1) {
byte_at.push(i);
}
byte_at.push(text.len());
Self { chars, byte_at }
}
fn cp_of_byte(&self, byte: usize) -> usize {
self.byte_at.partition_point(|&b| b < byte)
}
}
#[allow(
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "backward scan over code-point indices; positions stay within 0..=close_paren_pos, so the casts never truncate or lose sign"
)]
fn ldap_breakout_backward_window(chars: &[char], close_paren_pos: usize) -> (String, i64, bool) {
let backward_start = close_paren_pos.saturating_sub(40);
let mut position: i64 = close_paren_pos as i64 - 1;
let mut depth: i64 = 0;
while position >= backward_start as i64 {
let c = chars[position as usize];
if matches!(c, '"' | '\'' | '\n' | '&') {
break;
}
match c {
')' => depth -= 1,
'(' => depth += 1,
_ => {}
}
position -= 1;
}
let from = (position + 1).max(0) as usize;
let window = chars[from..close_paren_pos].iter().collect();
let depth_unresolved = backward_start > 0 && position < backward_start as i64;
(window, depth, depth_unresolved)
}
fn ldap_next_candidate_scan_limit(
compiled: &PyRegex,
haystack: &str,
span: &CharSpan,
after: usize,
) -> usize {
let byte_after = span.byte_at[after.min(span.chars.len())];
compiled
.re()
.find_at(haystack, byte_after)
.map_or(span.chars.len(), |m| span.cp_of_byte(m.end()))
}
#[must_use]
fn ldap_filter_expression_forward_extent(chars: &[char], start: usize, scan_limit: usize) -> usize {
let mut position = start;
let mut depth: i64 = 0;
loop {
let Some(rel) = chars[position.min(scan_limit)..scan_limit]
.iter()
.position(|&c| matches!(c, '(' | ')' | '"' | '\'' | '\n'))
else {
return scan_limit;
};
let at = position + rel;
match chars[at] {
'"' | '\'' | '\n' => return at,
'(' => depth += 1,
')' if depth == 0 => return at,
')' => depth -= 1,
_ => unreachable!("position filter above"),
}
position = at + 1;
}
}
fn ldap_breakout_forward_window(
compiled: &PyRegex,
haystack: &str,
span: &CharSpan,
candidate_end: usize,
close_paren_pos: usize,
) -> String {
let scan_limit = ldap_next_candidate_scan_limit(compiled, haystack, span, candidate_end);
let extent =
ldap_filter_expression_forward_extent(&span.chars, close_paren_pos + 1, scan_limit);
span.chars[close_paren_pos..extent].iter().collect()
}
fn search_in(source: &str, text: &str) -> bool {
let Ok(re) = PyRegex::compile(source, true) else {
return false;
};
re.re().is_match(text)
}
#[must_use]
pub fn ldap_wildcard_chain_is_injection(
compiled: &PyRegex,
haystack: &str,
candidate: &Candidate,
) -> bool {
let span = CharSpan::new(haystack);
let c_start = span.cp_of_byte(candidate.start);
let c_end = span.cp_of_byte(candidate.end);
let cand_chars = &span.chars[c_start..c_end];
let Some(paren_rel) = cand_chars.iter().position(|&c| c == ')') else {
return false;
};
let close_paren_pos = c_start + paren_rel;
let (backward_window, depth, depth_unresolved) =
ldap_breakout_backward_window(&span.chars, close_paren_pos);
let forward = ldap_breakout_forward_window(compiled, haystack, &span, c_end, close_paren_pos);
let wildcard_adjacent = cand_chars.first() == Some(&'*');
let depth_proves_breakout = depth <= 0 && (wildcard_adjacent || !depth_unresolved);
let wildcard_clause_end = search_in(r"=[^()]+\*\s*\z", &backward_window);
if !(depth_proves_breakout || wildcard_clause_end) {
return false;
}
let attack_token = r"\*|\(\s*[&|!]|\x00|\(\s*\(|~=|>=|<=";
search_in(attack_token, &backward_window) || search_in(attack_token, &forward)
}
#[must_use]
pub fn ldap_paren_conjunction_is_injection(
compiled: &PyRegex,
haystack: &str,
candidate: &Candidate,
) -> bool {
let span = CharSpan::new(haystack);
let c_end = span.cp_of_byte(candidate.end);
let scan_limit = ldap_next_candidate_scan_limit(compiled, haystack, &span, c_end);
let tail_end = ldap_filter_expression_forward_extent(&span.chars, c_end, scan_limit);
let tail: String = span.chars[c_end..tail_end].iter().collect();
if search_in(r"\A\s*(?:[!(]|\*)", &tail) {
return true;
}
if !tail.contains('=') {
return false;
}
search_in(
r"\A\s*(?::)?(?:[a-zA-Z][\w.-]*|\d+(?:\.\d+)*)(?:;[\w.-]+)*(?::[\w.-]+)*\s*:?=",
&tail,
)
}
#[must_use]
pub fn source_extension_path_is_probe(validator_context: &str) -> bool {
!validator_context.ends_with(":embedded_json")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_ipv4_decoding() {
assert_eq!(decode_legacy_ipv4_host("127.0.0.1"), Some(0x7f00_0001));
assert_eq!(decode_legacy_ipv4_host("0x7f.0.0.1"), Some(0x7f00_0001));
assert_eq!(decode_legacy_ipv4_host("0177.0.0.1"), Some(0x7f00_0001));
assert_eq!(decode_legacy_ipv4_host("2130706433"), Some(0x7f00_0001));
assert_eq!(decode_legacy_ipv4_host("966"), None);
assert_eq!(decode_legacy_ipv4_host("1.2.3.4.5"), None);
assert_eq!(decode_legacy_ipv4_host("256.1.1.1"), None);
}
#[test]
fn blocked_networks() {
assert!(is_blocked_legacy_ipv4(0x7f00_0001));
assert!(is_blocked_legacy_ipv4(0x0a00_0001));
assert!(!is_blocked_legacy_ipv4(0x0808_0808));
}
#[test]
fn legacy_ipv4_matcher_finds_blocked_host() {
let ms = legacy_ipv4_finditer("curl http://127.0.0.1:8080/");
assert_eq!(ms.len(), 1);
assert!(legacy_ipv4_match_is_blocked(
ms[0].text("curl http://127.0.0.1:8080/")
));
let ms = legacy_ipv4_finditer("curl http://example.com/");
assert!(
ms.is_empty() || !legacy_ipv4_match_is_blocked(ms[0].text("curl http://example.com/"))
);
}
#[test]
fn ldap_conjunction_followup_decides() {
let compiled = PyRegex::compile(LDAP_PAREN_CONJUNCTION_RE, false).unwrap();
let haystack = "(&(objectClass=user))";
let c = compiled.re().find(haystack).unwrap();
let cand = Candidate::new(c.start(), c.end());
assert!(ldap_paren_conjunction_is_injection(
&compiled, haystack, &cand
));
let haystack = "(|x";
let c = compiled.re().find(haystack).unwrap();
let cand = Candidate::new(c.start(), c.end());
assert!(!ldap_paren_conjunction_is_injection(
&compiled, haystack, &cand
));
}
#[test]
fn ldap_conjunction_accepts_followup_symbol() {
let compiled = PyRegex::compile(LDAP_PAREN_CONJUNCTION_RE, false).unwrap();
let haystack = "(&(cn=*)(!(cn=admin)))";
let c = compiled.re().find(haystack).unwrap();
let cand = Candidate::new(c.start(), c.end());
assert!(ldap_paren_conjunction_is_injection(
&compiled, haystack, &cand
));
}
#[test]
fn wildcard_chain_detects_breakout() {
let compiled = PyRegex::compile(LDAP_WILDCARD_CHAIN_RE, false).unwrap();
let haystack = "(uid=*)((mail=*";
let c = compiled.re().find(haystack).unwrap();
let cand = Candidate::new(c.start(), c.end());
assert!(!ldap_wildcard_chain_is_injection(
&compiled, haystack, &cand
));
let haystack = "x)(uid=*)(mail=*";
let c = compiled.re().find(haystack).unwrap();
let cand = Candidate::new(c.start(), c.end());
assert!(ldap_wildcard_chain_is_injection(&compiled, haystack, &cand));
}
}