Skip to content

Commit 10ed03c

Browse files
committed
ascanrules: Address External Redirect False Positives
Signed-off-by: kingthorin <[email protected]>
1 parent 1f19fbe commit 10ed03c

File tree

3 files changed

+203
-1
lines changed

3 files changed

+203
-1
lines changed

addOns/ascanrules/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1919
- For Alerts raised by the SQL Injection scan rules the Attack field values are now simply the payload, not an assembled description.
2020
- The Cross Site Scripting (Reflected) scan rule was updated to address potential false negatives when the injection context is a tag name and there is some filtering.
2121
- The Path Traversal scan rule now includes further details when directory matches are made (Issue 8379).
22+
- The External Redirect scan rules has been updated to account for potential false positives involving JavaScript comments.
2223

2324
### Added
2425
- Rules (as applicable) have been tagged in relation to HIPAA and PCI DSS.

addOns/ascanrules/src/main/java/org/zaproxy/zap/extension/ascanrules/ExternalRedirectScanRule.java

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
import java.util.ArrayList;
2424
import java.util.Collections;
2525
import java.util.HashMap;
26+
import java.util.HashSet;
2627
import java.util.List;
2728
import java.util.Map;
29+
import java.util.Set;
2830
import java.util.UUID;
2931
import java.util.regex.Matcher;
3032
import java.util.regex.Pattern;
@@ -489,11 +491,169 @@ private static RedirectType isRedirected(String payload, HttpMessage msg) {
489491
}
490492

491493
private static boolean isRedirectPresent(Pattern pattern, String value) {
492-
Matcher matcher = pattern.matcher(value);
494+
Set<String> extractedComments = extractJsComments(value);
495+
String[] valueWithoutComments = {value};
496+
extractedComments.forEach(
497+
comment -> valueWithoutComments[0] = valueWithoutComments[0].replace(comment, ""));
498+
499+
Matcher matcher = pattern.matcher(valueWithoutComments[0]);
500+
493501
return matcher.find()
494502
&& StringUtils.startsWithIgnoreCase(matcher.group(1), HttpHeader.HTTP);
495503
}
496504

505+
private static Set<String> extractJsComments(String js) {
506+
Set<String> comments = new HashSet<>();
507+
508+
final int n = js.length();
509+
boolean inSingle = false; // '...'
510+
boolean inDouble = false; // "..."
511+
int i = 0;
512+
513+
while (i < n) {
514+
char c = js.charAt(i);
515+
516+
// Inside a quoted string? Only look for the matching quote, consuming full escapes.
517+
if (inSingle || inDouble) {
518+
if (c == '\\') {
519+
i = consumeJsEscape(js, i); // Returns index of the last char of the escape
520+
} else if (inSingle && c == '\'') {
521+
inSingle = false;
522+
} else if (inDouble && c == '"') {
523+
inDouble = false;
524+
}
525+
i++;
526+
continue;
527+
}
528+
529+
// Not inside a string: maybe we’re entering one?
530+
if (c == '\'') {
531+
inSingle = true;
532+
i++;
533+
continue;
534+
}
535+
if (c == '"') {
536+
inDouble = true;
537+
i++;
538+
continue;
539+
}
540+
541+
// Not in a string: check for comments
542+
if (c == '/' && i + 1 < n) {
543+
char d = js.charAt(i + 1);
544+
545+
// Single-line //...
546+
if (d == '/') {
547+
int end = i + 2;
548+
while (end < n && !isJsLineTerminator(js.charAt(end))) end++;
549+
comments.add(js.substring(i, end));
550+
i = end; // position at line break (or end)
551+
continue;
552+
}
553+
554+
// Multi-line /* ... */
555+
if (d == '*') {
556+
int end = js.indexOf("*/", i + 2);
557+
if (end == -1) {
558+
// Unterminated: consume to end
559+
comments.add(js.substring(i));
560+
i = n;
561+
} else {
562+
comments.add(js.substring(i, end + 2));
563+
i = end + 2;
564+
}
565+
continue;
566+
}
567+
}
568+
569+
// Otherwise, just move on.
570+
i++;
571+
}
572+
573+
return comments;
574+
}
575+
576+
/**
577+
* Consumes a full JS escape sequence starting at the backslash. Returns the index of the last
578+
* character that belongs to the escape. Handles: \n, \r, \t, \b, \f, \v, \0, \', \", \\,
579+
* line-continuations, \xHH, \uFFFF, \\u{...}
580+
*/
581+
private static int consumeJsEscape(String s, int backslash) {
582+
int n = s.length();
583+
int i = backslash;
584+
if (i + 1 >= n) {
585+
return i; // Nothing to consume after '\'
586+
}
587+
588+
char e = s.charAt(i + 1);
589+
590+
// Line continuation: backslash followed by a line terminator
591+
if (isJsLineTerminator(e)) {
592+
// Consume \r\n as a unit if present
593+
if (e == '\r' && i + 2 < n && s.charAt(i + 2) == '\n') {
594+
return i + 2;
595+
}
596+
return i + 1;
597+
}
598+
599+
// \xHH (2 hex digits)
600+
if (e == 'x' || e == 'X') {
601+
int j = i + 2;
602+
int consumed = 0;
603+
while (j < n && consumed < 2 && isHexDigit(s.charAt(j))) {
604+
j++;
605+
consumed++;
606+
}
607+
// Even if malformed, we stop at the last hex digit we found
608+
return j - 1;
609+
}
610+
611+
// \uFFFF or \\u{...}
612+
if (e == 'u' || e == 'U') {
613+
int j = i + 2;
614+
if (j < n && s.charAt(j) == '{') {
615+
// \\u{hex+}
616+
j++;
617+
while (j < n && isHexDigit(s.charAt(j))) {
618+
j++;
619+
}
620+
if (j < n && s.charAt(j) == '}') j++; // Close if present
621+
return j - 1; // End of } or last hex if malformed
622+
} else {
623+
// \\uHHHH (exactly 4 hex if well-formed)
624+
int consumed = 0;
625+
while (j < n && consumed < 4 && isHexDigit(s.charAt(j))) {
626+
j++;
627+
consumed++;
628+
}
629+
return j - 1;
630+
}
631+
}
632+
633+
// Octal escapes (legacy). Consume up to 3 octal digits if present.
634+
if (e >= '0' && e <= '7') {
635+
int j = i + 1;
636+
int consumed = 0;
637+
while (j < n && consumed < 3 && s.charAt(j) >= '0' && s.charAt(j) <= '7') {
638+
j++;
639+
consumed++;
640+
}
641+
return j - 1;
642+
}
643+
644+
// Simple one-char escapes: \n \r \t \b \f \v \0 \' \" \\
645+
return i + 1;
646+
}
647+
648+
private static boolean isHexDigit(char c) {
649+
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
650+
}
651+
652+
private static boolean isJsLineTerminator(char c) {
653+
// JS line terminators: LF, CR, LS, PS
654+
return c == '\n' || c == '\r' || c == '\u2028' || c == '\u2029';
655+
}
656+
497657
/**
498658
* Give back the risk associated to this vulnerability (high)
499659
*

addOns/ascanrules/src/test/java/org/zaproxy/zap/extension/ascanrules/ExternalRedirectScanRuleUnitTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,47 @@ void shouldReportRedirectWithJsLocationMethods(String jsMethod) throws Exception
531531
assertThat(alertsRaised.get(0).getEvidence().startsWith(HttpHeader.HTTP), equalTo(true));
532532
}
533533

534+
@Test
535+
void shouldNotReportRedirectIfInsideJsComment() throws Exception {
536+
// Given
537+
String test = "/";
538+
String body =
539+
"""
540+
<!DOCTYPE html>
541+
<html>
542+
<head>
543+
<title>Redirect commented out</title>
544+
</head>
545+
<body>
546+
547+
<script>function myRedirectFunction()
548+
{/*
549+
window.location.replace('@@@content@@@');
550+
*/}
551+
//myRedirectFunction();</script>
552+
""";
553+
nano.addHandler(
554+
new NanoServerHandler(test) {
555+
@Override
556+
protected NanoHTTPD.Response serve(NanoHTTPD.IHTTPSession session) {
557+
String site = getFirstParamValue(session, "site");
558+
if (site != null && !site.isEmpty()) {
559+
return newFixedLengthResponse(
560+
NanoHTTPD.Response.Status.OK,
561+
NanoHTTPD.MIME_HTML,
562+
body.replace(CONTENT_TOKEN, site));
563+
}
564+
return newFixedLengthResponse("<html><body></body></html>");
565+
}
566+
});
567+
HttpMessage msg = getHttpMessage(test + "?site=xxx");
568+
rule.init(msg, parent);
569+
// When
570+
rule.scan();
571+
// Then
572+
assertThat(alertsRaised.size(), equalTo(0));
573+
}
574+
534575
private static Stream<Arguments> createJsMethodBooleanPairs() {
535576
return Stream.of(
536577
Arguments.of("location.reload", true),

0 commit comments

Comments
 (0)