1use super::chars_util::char_before;
9use super::pyregex::PyRegex;
10
11pub const AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS: &[&str] = &["query_param", "url_path"];
12
13const STRONG_SQL_KEYWORD_GLUED_PREFIX_RE: &str =
14 r"\b(?:SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|JOIN|VALUES|ORDER\s+BY|GROUP\s+BY)\z";
15const STRONG_SQL_KEYWORD_GLUED_SUFFIX_RE: &str =
16 r"\A(?:SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|JOIN|VALUES|ORDER\s+BY|GROUP\s+BY)\b";
17const BARE_SHELL_PARAMETER_NAME_RE: &str =
18 r"\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z";
19
20const BACKTICK_WINDOW_DELIMITERS: &[char] = &['`', '\'', '"', '\n', '\r'];
21
22const fn is_ascii_word(c: char) -> bool {
23 c.is_ascii_alphanumeric() || c == '_'
24}
25
26fn shell_text_is_printable_ascii(token: &str) -> bool {
27 token
28 .chars()
29 .all(|c| c == '\t' || ('\u{20}'..='\u{7e}').contains(&c))
30}
31
32#[allow(clippy::missing_const_for_fn)] fn backtick_token_has_chained_shell_operators(token: &str) -> bool {
34 let mut count = 0usize;
35 let bytes = token.as_bytes();
36 let mut i = 0usize;
37 while i < bytes.len() {
38 match bytes[i] {
39 b';' => {
40 count += 1;
41 i += 1;
42 }
43 b'|' | b'&' => {
44 count += 1;
45 if i + 1 < bytes.len() && bytes[i + 1] == bytes[i] {
46 i += 2;
47 } else {
48 i += 1;
49 }
50 }
51 _ => i += 1,
52 }
53 }
54 count >= 2
55}
56
57fn backtick_pair_glued(content: &str, start: usize, end: usize) -> bool {
58 let prefix_glued =
59 start > 0 && char_before(content, start).is_some_and(|(_, c)| is_ascii_word(c));
60 let suffix_glued = content[end..].chars().next().is_some_and(is_ascii_word);
61 prefix_glued || suffix_glued
62}
63
64const BACKTICK_CLAUSE_BOUNDARY_CHARS: &[char] = &['.', '!', '?', ';', '&', '|'];
65
66fn backtick_pair_tail_anchored(content: &str, end: usize) -> bool {
67 content[end..].trim().is_empty()
68}
69
70fn backtick_pair_clause_initial(content: &str, start: usize) -> bool {
71 if start == 0 {
72 return false;
73 }
74 if !char_before(content, start).is_some_and(|(_, c)| matches!(c, ' ' | '\t' | '\r' | '\n')) {
75 return false;
76 }
77 let prefix = content[..start].trim_end();
78 if prefix.is_empty() {
79 return false;
80 }
81 prefix
82 .chars()
83 .next_back()
84 .is_some_and(|c| BACKTICK_CLAUSE_BOUNDARY_CHARS.contains(&c))
85}
86
87fn backtick_pair_appended_clause(content: &str, start: usize, end: usize) -> bool {
88 backtick_pair_tail_anchored(content, end) && backtick_pair_clause_initial(content, start)
89}
90
91fn backtick_window_start(content: &str, position: usize) -> usize {
92 let mut index = position;
93 while index > 0
94 && char_before(content, index)
95 .is_some_and(|(_, c)| !BACKTICK_WINDOW_DELIMITERS.contains(&c))
96 {
97 index = char_before(content, index).map_or(0, |(i, _)| i);
98 }
99 index
100}
101
102fn backtick_window_end(content: &str, position: usize) -> usize {
103 content[position..]
104 .char_indices()
105 .find(|(_, c)| BACKTICK_WINDOW_DELIMITERS.contains(c))
106 .map_or(content.len(), |(i, _)| position + i)
107}
108
109fn backtick_pair_context_window(content: &str, start: usize, end: usize) -> &str {
110 let window_start = backtick_window_start(content, start);
111 let window_end = backtick_window_end(content, end);
112 &content[window_start..window_end]
113}
114
115fn token_is_implausible_sql_identifier(token: &str) -> bool {
116 token.chars().any(|c| {
118 matches!(
119 c,
120 ' ' | '\t'
121 | '\n'
122 | '\r'
123 | '\x0b'
124 | '\x0c'
125 | '/'
126 | '.'
127 | ';'
128 | '|'
129 | '&'
130 | '$'
131 | '('
132 | ')'
133 )
134 })
135}
136
137fn strong_sql_keyword_glued_to_pair(content: &str, start: usize, end: usize) -> bool {
138 let window_start = backtick_window_start(content, start);
139 let window_end = backtick_window_end(content, end);
140 let prefix = &content[window_start..start];
141 let suffix = &content[end..window_end];
142 #[cfg(not(coverage))] let Ok(prefix_re) = PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_PREFIX_RE, true) else {
144 return false;
145 };
146 #[cfg(coverage)]
147 let prefix_re =
148 PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_PREFIX_RE, true).expect("statically valid");
149 if prefix_re.re().is_match(prefix) {
150 return true;
151 }
152 #[cfg(not(coverage))] let Ok(suffix_re) = PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_SUFFIX_RE, true) else {
154 return false;
155 };
156 #[cfg(coverage)]
157 let suffix_re =
158 PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_SUFFIX_RE, true).expect("statically valid");
159 suffix_re.re().is_match(suffix)
160}
161
162#[must_use]
164pub fn glued_backtick_pair_is_injection(
165 content: &str,
166 candidate: super::pyregex::Candidate,
167 request_context: &str,
168) -> bool {
169 let start = candidate.start;
170 let end = candidate.end;
171 let token = &content[start + 1..end - 1];
172 if !shell_text_is_printable_ascii(token) {
173 return false;
174 }
175 if backtick_token_has_chained_shell_operators(token) {
176 return true;
177 }
178 let appended_clause = backtick_pair_appended_clause(content, start, end);
179 if !backtick_pair_glued(content, start, end) && !appended_clause {
180 return false;
181 }
182 if token_is_implausible_sql_identifier(token) {
183 return true;
184 }
185 let window = backtick_pair_context_window(content, start, end);
186 if shell_metacharacter_window(window) {
187 return true;
188 }
189 if strong_sql_keyword_glued_to_pair(content, start, end) {
190 return false;
191 }
192 let normalized = request_context.split(':').next().unwrap_or(request_context);
193 AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS.contains(&normalized) || appended_clause
194}
195
196#[allow(clippy::missing_const_for_fn)] fn shell_metacharacter_window(window: &str) -> bool {
199 let bytes = window.as_bytes();
200 let mut i = 0usize;
201 while i < bytes.len() {
202 let op_len = if bytes[i] == b';' {
204 1
205 } else if (bytes[i] == b'|' || bytes[i] == b'&')
206 && i + 1 < bytes.len()
207 && bytes[i + 1] == bytes[i]
208 {
209 2
210 } else if bytes[i] == b'|' || bytes[i] == b'&' {
211 1
212 } else {
213 i += 1;
214 continue;
215 };
216 let mut j = i + op_len;
217 while j < bytes.len() && (bytes[j] as char).is_whitespace() {
218 j += 1;
219 }
220 if j >= bytes.len() {
221 i += op_len;
222 continue;
223 }
224 if bytes[j] == b'`' {
226 return true;
227 }
228 if bytes[j] == b'$' && j + 1 < bytes.len() && (bytes[j + 1] == b'(' || bytes[j + 1] == b'{')
229 {
230 return true;
231 }
232 if bytes[j] == b'_' || bytes[j].is_ascii_alphabetic() {
234 let mut k = j + 1;
235 while k < bytes.len()
236 && (bytes[k] == b'_' || bytes[k] == b'-' || bytes[k].is_ascii_alphanumeric())
237 {
238 k += 1;
239 }
240 return true;
241 }
242 if matches!(bytes[j], b'~' | b'.' | b'/') {
244 let mut k = j + 1;
245 while k < bytes.len()
246 && (bytes[k] == b'_'
247 || bytes[k] == b'.'
248 || bytes[k] == b'/'
249 || bytes[k] == b'-'
250 || bytes[k].is_ascii_alphanumeric())
251 {
252 k += 1;
253 }
254 return true;
255 }
256 if bytes[j] == b'-' {
258 return true;
259 }
260 i = j;
261 }
262 false
263}
264
265fn dollar_substitution_token_is_implausible(token: &str, delimiter: char) -> bool {
266 let stripped = token.trim().to_lowercase();
267 if stripped == "ifs" {
268 return true;
269 }
270 if delimiter == '{' {
271 #[cfg(not(coverage))] let Ok(re) = PyRegex::compile(BARE_SHELL_PARAMETER_NAME_RE, false) else {
273 return true;
274 };
275 #[cfg(coverage)]
276 let re = PyRegex::compile(BARE_SHELL_PARAMETER_NAME_RE, false).expect("statically valid");
277 return re
278 .re()
279 .find(token.trim())
280 .is_none_or(|m| m.start() != 0 || m.end() != token.trim().len());
281 }
282 token
284 .chars()
285 .any(|c| matches!(c, '/' | '.' | ';' | '|' | '&' | '$' | '(' | ')'))
286}
287
288fn dollar_substitution_pair_backtick_quoted(content: &str, start: usize, end: usize) -> bool {
289 let prefix_quoted = start > 0 && char_before(content, start).is_some_and(|(_, c)| c == '`');
290 let suffix_quoted = content[end..].chars().next().is_some_and(|c| c == '`');
291 prefix_quoted || suffix_quoted
292}
293
294#[must_use]
296pub fn dollar_substitution_pair_is_injection(
297 content: &str,
298 candidate: super::pyregex::Candidate,
299 request_context: &str,
300) -> bool {
301 let start = candidate.start;
302 let end = candidate.end;
303 if dollar_substitution_pair_backtick_quoted(content, start, end) {
304 return false;
305 }
306 let Some(delimiter) = content[start + 1..].chars().next() else {
307 return false;
308 };
309 let token = &content[start + 2..end - 1];
310 if dollar_substitution_token_is_implausible(token, delimiter) {
311 return true;
312 }
313 if strong_sql_keyword_glued_to_pair(content, start, end) {
314 return false;
315 }
316 let normalized = request_context.split(':').next().unwrap_or(request_context);
317 AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS.contains(&normalized)
318}
319
320#[must_use]
323pub fn quote_splice_token_is_dangerous_command(token: &str) -> bool {
324 let mut run = 0usize;
325 for fragment in token.split(['\'', '"']) {
326 let chars = fragment.chars().count();
327 run = if chars == 1 { run + 1 } else { 0 };
328 if run >= 3 {
329 return true;
330 }
331 }
332 false
333}
334
335const GLOB_WILDCARD_COMMAND_SUFFIX_CHARS: &[char] = &[' ', '\t', '\r', '\n', ';', '|', '&'];
336const GLOB_WILDCARD_VALUE_START_CONTEXTS: &[&str] = &["request_body"];
337
338fn glob_wildcard_token_is_word_shaped(token: &str) -> bool {
339 let chars: Vec<char> = token.chars().collect();
340 for (index, c) in chars.iter().enumerate() {
341 if *c != '?' && *c != '*' {
342 continue;
343 }
344 let mut left = 0usize;
345 let mut position = index;
346 while position > 0 && chars[position - 1].is_ascii_alphabetic() {
347 left += 1;
348 position -= 1;
349 }
350 let mut right = 0usize;
351 position = index + 1;
352 while position < chars.len() && chars[position].is_ascii_alphabetic() {
353 right += 1;
354 position += 1;
355 }
356 if left + right >= 2 {
357 return true;
358 }
359 }
360 false
361}
362
363#[must_use]
365pub fn glob_wildcard_token_is_dangerous_command(
366 content: &str,
367 candidate: super::pyregex::Candidate,
368 request_context: &str,
369) -> bool {
370 if !glob_wildcard_token_is_word_shaped(candidate.text(content)) {
371 return false;
372 }
373 let suffix = content[candidate.end..].chars().next();
374 if let Some(c) = suffix
375 && !GLOB_WILDCARD_COMMAND_SUFFIX_CHARS.contains(&c)
376 {
377 return false;
378 }
379 let prefix = &content[..candidate.start];
380 if glob_command_boundary_prefix(prefix) {
381 return true;
382 }
383 if GLOB_WILDCARD_VALUE_START_CONTEXTS.contains(&request_context) {
384 return prefix.trim().is_empty();
385 }
386 false
387}
388
389fn glob_command_boundary_prefix(prefix: &str) -> bool {
391 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
392 let Some(last) = trimmed.chars().next_back() else {
393 return false;
394 };
395 match last {
396 ';' | '`' | '|' | '&' => true,
398 '$' => trimmed.as_bytes().get(trimmed.len() - 2) == Some(&b'('),
399 _ => false,
400 }
401}
402
403#[must_use]
406pub fn brace_expansion_is_dangerous_command(candidate_text: &str) -> bool {
407 let Some(start) = candidate_text.find('{') else {
408 return false;
409 };
410 let Some(end) = candidate_text.rfind('}') else {
411 return false;
412 };
413 if end <= start {
414 return false;
415 }
416 candidate_text[start + 1..end].split(',').any(|item| {
417 !item.is_empty()
418 && item
419 .chars()
420 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '~' | '-'))
421 && item.chars().any(|c| c.is_ascii_alphabetic())
422 })
423}
424
425#[cfg(test)]
426mod tests {
427 use super::super::pyregex::Candidate;
428 use super::*;
429
430 #[test]
431 fn quote_splice_three_single_char_fragments() {
432 assert!(quote_splice_token_is_dangerous_command("a'b'c"));
433 assert!(!quote_splice_token_is_dangerous_command("ab'cd"));
434 assert!(!quote_splice_token_is_dangerous_command("it's"));
435 }
436
437 #[test]
438 fn brace_expansion_word_items() {
439 assert!(brace_expansion_is_dangerous_command(";{a,b,c}"));
440 assert!(brace_expansion_is_dangerous_command(";{echo,id}"));
441 assert!(!brace_expansion_is_dangerous_command(";{1,2,3}"));
442 assert!(brace_expansion_is_dangerous_command(";{a-b,c}"));
444 }
445
446 #[test]
447 fn glob_word_shaped_after_boundary() {
448 assert!(!glob_wildcard_token_is_dangerous_command(
450 "ls *.txt",
451 Candidate::new(3, 8),
452 "unknown"
453 ));
454 assert!(glob_wildcard_token_is_dangerous_command(
456 "; file* ",
457 Candidate::new(2, 7),
458 "unknown"
459 ));
460 assert!(!glob_wildcard_token_is_dangerous_command(
462 "file* ",
463 Candidate::new(0, 5),
464 "unknown"
465 ));
466 }
467
468 #[test]
469 fn glob_value_start_only_in_request_body() {
470 assert!(glob_wildcard_token_is_dangerous_command(
471 "file* ",
472 Candidate::new(0, 5),
473 "request_body"
474 ));
475 assert!(!glob_wildcard_token_is_dangerous_command(
476 "file* ",
477 Candidate::new(0, 5),
478 "query_param"
479 ));
480 }
481
482 #[test]
483 fn backtick_glued_injection() {
484 let content = "x`id`y";
486 assert!(glued_backtick_pair_is_injection(
487 content,
488 Candidate::new(1, 5),
489 "query_param"
490 ));
491 assert!(!glued_backtick_pair_is_injection(
492 content,
493 Candidate::new(1, 5),
494 "request_body"
495 ));
496 }
497
498 #[test]
499 fn backtick_sql_glue_negated() {
500 let content = "SELECT`a`FROM";
501 assert!(!glued_backtick_pair_is_injection(
502 content,
503 Candidate::new(6, 9),
504 "request_body"
505 ));
506 }
507
508 #[test]
509 fn dollar_substitution_implausible() {
510 let content = ";$(rm -rf /)";
512 assert!(dollar_substitution_pair_is_injection(
513 content,
514 Candidate::new(1, 12),
515 "request_body"
516 ));
517 let content = ";$(rm -rf)";
519 assert!(dollar_substitution_pair_is_injection(
520 content,
521 Candidate::new(1, 9),
522 "query_param"
523 ));
524 let content = ";${IFS}";
525 assert!(dollar_substitution_pair_is_injection(
526 content,
527 Candidate::new(1, 7),
528 "request_body"
529 ));
530 let content = ";${PATH}";
531 assert!(!dollar_substitution_pair_is_injection(
532 content,
533 Candidate::new(1, 8),
534 "request_body"
535 ));
536 }
537}
538
539#[cfg(test)]
540mod coverage_tests {
541 use super::*;
542 use crate::patterns::pyregex::Candidate;
543
544 fn pair(content: &str) -> Candidate {
545 let start = content.find('`').expect("open backtick");
547 let end = content[start + 1..].find('`').expect("close backtick") + start + 2;
548 Candidate::new(start, end)
549 }
550
551 #[test]
552 fn chained_shell_operators_count_single_and_doubled() {
553 assert!(backtick_token_has_chained_shell_operators("a;b|c"));
554 assert!(backtick_token_has_chained_shell_operators("a&b&&c"));
555 assert!(!backtick_token_has_chained_shell_operators("a&b"));
556 }
557
558 #[test]
559 fn glued_backtick_pairs_reject_unprintable_tokens_and_accept_chains() {
560 let content = "x`\u{7f}`y";
562 assert!(!glued_backtick_pair_is_injection(
563 content,
564 pair(content),
565 "arg"
566 ));
567 let content = "x`a&b&&c;d`y";
569 assert!(glued_backtick_pair_is_injection(
570 content,
571 pair(content),
572 "arg"
573 ));
574 }
575
576 #[test]
577 fn glued_backtick_pairs_classify_windows_and_contexts() {
578 let content = "x`a/b.c`y";
580 assert!(glued_backtick_pair_is_injection(
581 content,
582 pair(content),
583 "arg"
584 ));
585 let content = "a; `id`b";
587 assert!(glued_backtick_pair_is_injection(
588 content,
589 pair(content),
590 "arg"
591 ));
592 let content = "select * from `id` where";
594 assert!(!glued_backtick_pair_is_injection(
595 content,
596 pair(content),
597 "arg"
598 ));
599 let content = "x`id`y";
601 assert!(glued_backtick_pair_is_injection(
602 content,
603 pair(content),
604 "query_param"
605 ));
606 assert!(!glued_backtick_pair_is_injection(
607 content,
608 pair(content),
609 "arg"
610 ));
611 let content = "end. `id`";
613 assert!(glued_backtick_pair_is_injection(
614 content,
615 pair(content),
616 "arg"
617 ));
618 }
619
620 fn dollar_pair(content: &str) -> Candidate {
621 let start = content.find('$').expect("dollar");
623 let end = content[start..].find('}').expect("brace") + start + 1;
624 Candidate::new(start, end)
625 }
626
627 #[test]
628 fn dollar_pairs_reject_backtick_quoted_and_classify_tokens() {
629 let content = "`${IFS}`";
631 assert!(!dollar_substitution_pair_is_injection(
632 content,
633 dollar_pair(content),
634 "arg"
635 ));
636 let content = "x ${IFS} y";
638 assert!(dollar_substitution_pair_is_injection(
639 content,
640 dollar_pair(content),
641 "arg"
642 ));
643 let content = "x ${A-B} y";
645 assert!(dollar_substitution_pair_is_injection(
646 content,
647 dollar_pair(content),
648 "arg"
649 ));
650 let content = "x ${/bin/sh} y";
652 assert!(dollar_substitution_pair_is_injection(
653 content,
654 dollar_pair(content),
655 "arg"
656 ));
657 let content = "select ${id} from";
659 assert!(!dollar_substitution_pair_is_injection(
660 content,
661 dollar_pair(content),
662 "arg"
663 ));
664 let content = "x ${id} y";
666 assert!(dollar_substitution_pair_is_injection(
667 content,
668 dollar_pair(content),
669 "url_path"
670 ));
671 assert!(!dollar_substitution_pair_is_injection(
672 content,
673 dollar_pair(content),
674 "arg"
675 ));
676 }
677
678 #[test]
679 fn appended_clauses_and_context_windows_cover_the_clause_edges() {
680 let content = "end. `id`";
682 assert!(glued_backtick_pair_is_injection(
683 content,
684 pair(content),
685 "arg"
686 ));
687 let content = "`id` end.";
689 assert!(!glued_backtick_pair_is_injection(
690 content,
691 pair(content),
692 "arg"
693 ));
694 let content = "word `id` end.";
697 assert!(!glued_backtick_pair_is_injection(
698 content,
699 pair(content),
700 "arg"
701 ));
702 }
703
704 #[test]
705 fn glob_wildcards_reject_unshaped_tokens_and_value_start_contexts() {
706 let content = "cmd; * ";
708 assert!(!glob_wildcard_token_is_dangerous_command(
709 content,
710 Candidate::new(5, 6),
711 "arg"
712 ));
713 let content = "xy* ";
716 assert!(glob_wildcard_token_is_dangerous_command(
717 content,
718 Candidate::new(0, 3),
719 "request_body"
720 ));
721 assert!(!glob_wildcard_token_is_dangerous_command(
723 content,
724 Candidate::new(0, 3),
725 "arg"
726 ));
727 let content = "eval ($ xy* ;";
729 let start = content.find("xy*").expect("token");
730 assert!(glob_wildcard_token_is_dangerous_command(
731 content,
732 Candidate::new(start, start + 3),
733 "arg"
734 ));
735 }
736
737 #[test]
738 fn brace_expansion_guards_reject_malformed_candidates() {
739 assert!(!brace_expansion_is_dangerous_command("no braces"));
740 assert!(!brace_expansion_is_dangerous_command("{unclosed"));
741 assert!(!brace_expansion_is_dangerous_command("}reversed{"));
742 assert!(!brace_expansion_is_dangerous_command("{}"));
743 assert!(brace_expansion_is_dangerous_command("{a,b}"));
744 assert!(!brace_expansion_is_dangerous_command("{1,2}"));
745 }
746
747 #[test]
748 fn glued_backtick_windows_cover_every_metacharacter_shape() {
749 let content = "x`a`b||cmd";
751 assert!(glued_backtick_pair_is_injection(
752 content,
753 pair(content),
754 "arg"
755 ));
756 assert!(!glued_backtick_pair_is_injection(
759 "x`a`b;",
760 pair("x`a`b;"),
761 "arg"
762 ));
763 let content = "x;`a`b";
765 assert!(glued_backtick_pair_is_injection(
766 content,
767 pair(content),
768 "arg"
769 ));
770 let content = "x;$(a `a`b";
772 assert!(glued_backtick_pair_is_injection(
773 content,
774 pair(content),
775 "arg"
776 ));
777 let content = "x;~/x `a`b";
779 assert!(glued_backtick_pair_is_injection(
780 content,
781 pair(content),
782 "arg"
783 ));
784 let content = "x;-v `a`b";
786 assert!(glued_backtick_pair_is_injection(
787 content,
788 pair(content),
789 "arg"
790 ));
791 }
792
793 #[test]
794 fn appended_clause_edges_reject_boundaryless_and_empty_prefixes() {
795 let content = "word. `id` tail";
798 assert!(!glued_backtick_pair_is_injection(
799 content,
800 pair(content),
801 "arg"
802 ));
803 let content = "`id` end.";
805 assert!(!glued_backtick_pair_is_injection(
806 content,
807 pair(content),
808 "arg"
809 ));
810 let content = "end.`id` tail";
813 assert!(!glued_backtick_pair_is_injection(
814 content,
815 pair(content),
816 "arg"
817 ));
818 let content = " `id` tail";
821 assert!(!glued_backtick_pair_is_injection(
822 content,
823 pair(content),
824 "arg"
825 ));
826 }
827
828 #[test]
829 fn glob_wildcards_after_a_command_boundary_are_dangerous() {
830 let content = "eval; ls*.txt ";
833 let candidate = Candidate::new(6, 14);
834 assert!(glob_wildcard_token_is_dangerous_command(
835 content, candidate, "arg"
836 ));
837 }
838}
839
840#[cfg(test)]
841mod gap_tests {
842 use super::*;
843 use crate::patterns::pyregex::Candidate;
844
845 #[test]
846 fn appended_clause_requires_whitespace_before_the_pair() {
847 let content = "end.`id`";
850 assert!(!glued_backtick_pair_is_injection(
851 content,
852 Candidate::new(4, 8),
853 "arg"
854 ));
855 }
856
857 #[test]
858 fn appended_clause_rejects_a_whitespace_only_prefix() {
859 let content = " \t`id`";
862 assert!(!glued_backtick_pair_is_injection(
863 content,
864 Candidate::new(2, 6),
865 "arg"
866 ));
867 }
868
869 #[test]
870 fn a_strong_sql_keyword_glued_to_a_dollar_pair_vetoes_it() {
871 let content = "SELECT${id}";
873 assert!(!dollar_substitution_pair_is_injection(
874 content,
875 Candidate::new(6, 11),
876 "url_path"
877 ));
878 let content = "${id}FROM";
880 assert!(!dollar_substitution_pair_is_injection(
881 content,
882 Candidate::new(0, 5),
883 "url_path"
884 ));
885 }
886}
887
888#[cfg(test)]
889mod unit_twins {
890 use super::super::pyregex::Candidate;
891 use super::*;
892
893 #[test]
894 fn a_single_metacharacter_opens_the_window() {
895 assert!(shell_metacharacter_window("; |x"));
898 assert!(shell_metacharacter_window("&& &y"));
899 }
900
901 #[test]
902 fn a_dollar_substitution_after_an_operator_opens_the_window() {
903 assert!(shell_metacharacter_window("; $(cmd)"));
906 assert!(shell_metacharacter_window("|| ${VAR}"));
907 }
908
909 #[test]
910 fn an_unrecognized_post_operator_token_keeps_scanning() {
911 assert!(!shell_metacharacter_window("; @x"));
914 }
915
916 #[test]
917 fn a_dollar_pair_at_the_string_end_has_no_delimiter() {
918 let content = "x$";
921 assert!(!dollar_substitution_pair_is_injection(
922 content,
923 Candidate::new(1, 2),
924 "query_param"
925 ));
926 }
927
928 #[test]
929 fn a_dollar_paren_boundary_reads_both_command_forms() {
930 assert!(glob_command_boundary_prefix("eval ($"));
933 assert!(!glob_command_boundary_prefix("echo $HOME"));
935 assert!(!glob_command_boundary_prefix("echo $("));
938 assert!(glob_command_boundary_prefix("echo ;"));
940 assert!(glob_command_boundary_prefix("echo |"));
941 assert!(glob_command_boundary_prefix("echo &"));
942 assert!(glob_command_boundary_prefix("echo `"));
943 assert!(!glob_command_boundary_prefix(""));
944 }
945}