Skip to content

Commit 436c2b7

Browse files
committed
ascanrules: Address External Redirect False Positives
Signed-off-by: kingthorin <[email protected]> # Conflicts: # addOns/ascanrules/CHANGELOG.md
1 parent fa2c195 commit 436c2b7

File tree

3 files changed

+234
-1
lines changed

3 files changed

+234
-1
lines changed

addOns/ascanrules/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
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).
2222
- Add help details about behavior of scan rules which leverage OAST (Issue 8682).
23+
- The External Redirect scan rule has been updated to account for potential false positives involving JavaScript comments.
2324

2425
### Added
2526
- 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: 171 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;
@@ -137,6 +139,8 @@ public String getReason() {
137139
Pattern.compile(
138140
"(?i)window\\.(?:open|navigate)\\s*\\(\\s*['\"](" + SITE_PATT + ")['\"]");
139141

142+
private static final List<String> JS_PRE_CHECKS = List.of("window.", "location.", "location=");
143+
140144
/** The various (prioritized) payloads to try */
141145
private enum RedirectPayloads {
142146
PLAIN_SITE(REDIRECT_SITE, false),
@@ -476,11 +480,177 @@ private static RedirectType isRedirected(String payload, HttpMessage msg) {
476480
}
477481

478482
private static boolean isRedirectPresent(Pattern pattern, String value) {
479-
Matcher matcher = pattern.matcher(value);
483+
// Ensure the value has something we're interested in before dealing with comments
484+
if (!StringUtils.containsIgnoreCase(value, SITE_HOST)
485+
&& JS_PRE_CHECKS.stream()
486+
.noneMatch(chk -> StringUtils.containsIgnoreCase(value, chk))) {
487+
return false;
488+
}
489+
Set<String> extractedComments = extractJsComments(value);
490+
String valueWithoutComments = value;
491+
for (String comment : extractedComments) {
492+
valueWithoutComments = valueWithoutComments.replace(comment, "");
493+
}
494+
495+
Matcher matcher = pattern.matcher(valueWithoutComments);
496+
480497
return matcher.find()
481498
&& StringUtils.startsWithIgnoreCase(matcher.group(1), HttpHeader.HTTP);
482499
}
483500

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

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

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

534+
private static Stream<Arguments> provideCommentStrings() {
535+
return Stream.of(
536+
Arguments.of("Block comment", "/* window.location.replace('@@@content@@@');\n*/"),
537+
Arguments.of("Single line", "// window.location.replace('@@@content@@@');"),
538+
Arguments.of(
539+
"Inline block",
540+
"console.log(\"example\"); /* console.log('@@@content@@@'); */"),
541+
Arguments.of(
542+
"Inline single line",
543+
"console.log(\"example\"); // console.log('@@@content@@@');"),
544+
Arguments.of(
545+
"Inline single line (w/ unicode escape)",
546+
"console.log(\"🔥 example\"); // console.log('\u1F525 @@@content@@@');"),
547+
// Next needs double escape because of Java
548+
Arguments.of(
549+
"Inline single line (w/ hex escape)",
550+
"console.log(\"example\"); // console.log('\\xD83D @@@content@@@');"),
551+
Arguments.of(
552+
"Inline single line (w/ single char escapes)",
553+
"console.log(\"example\"); // console.log('\r\n\t@@@content@@@');"));
554+
}
555+
556+
@ParameterizedTest(name = "{0}")
557+
@MethodSource("provideCommentStrings")
558+
void shouldNotReportRedirectIfInsideJsComment(String name, String content) throws Exception {
559+
// Given
560+
String test = "/";
561+
String body =
562+
"""
563+
<!DOCTYPE html>
564+
<html>
565+
<head>
566+
<title>Redirect commented out</title>
567+
</head>
568+
<body>
569+
570+
<script>function myRedirectFunction()
571+
{%s}
572+
//myRedirectFunction();</script>
573+
"""
574+
.formatted(content);
575+
nano.addHandler(
576+
new NanoServerHandler(test) {
577+
@Override
578+
protected NanoHTTPD.Response serve(NanoHTTPD.IHTTPSession session) {
579+
String site = getFirstParamValue(session, "site");
580+
if (site != null && !site.isEmpty()) {
581+
String withPayload = body.replace(CONTENT_TOKEN, site);
582+
return newFixedLengthResponse(
583+
NanoHTTPD.Response.Status.OK, NanoHTTPD.MIME_HTML, withPayload);
584+
}
585+
return newFixedLengthResponse("<html><body></body></html>");
586+
}
587+
});
588+
HttpMessage msg = getHttpMessage(test + "?site=xxx");
589+
rule.init(msg, parent);
590+
// When
591+
rule.scan();
592+
// Then
593+
assertThat(alertsRaised.size(), equalTo(0));
594+
}
595+
534596
private static Stream<Arguments> createJsMethodBooleanPairs() {
535597
return Stream.of(
536598
Arguments.of("location.reload", true),

0 commit comments

Comments
 (0)