Skip to content

Commit 6b120f2

Browse files
committed
ascanrules: Address External Redirect False Positives
Signed-off-by: kingthorin <kingthorin@users.noreply.github.com>
1 parent 1f19fbe commit 6b120f2

File tree

3 files changed

+232
-1
lines changed

3 files changed

+232
-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: 169 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;
@@ -189,6 +191,7 @@ public String getRedirectUrl() {
189191
private static final Vulnerability VULN = Vulnerabilities.getDefault().get("wasc_38");
190192

191193
private static final Logger LOGGER = LogManager.getLogger(ExternalRedirectScanRule.class);
194+
private static final List<String> JS_PRE_CHECKS = List.of("window.", "location.", "location=");
192195

193196
private int payloadCount;
194197

@@ -489,11 +492,176 @@ private static RedirectType isRedirected(String payload, HttpMessage msg) {
489492
}
490493

491494
private static boolean isRedirectPresent(Pattern pattern, String value) {
492-
Matcher matcher = pattern.matcher(value);
495+
// Ensure the value has something we're interested in before dealing with comments
496+
if (!StringUtils.containsIgnoreCase(value, SITE_HOST)
497+
&& JS_PRE_CHECKS.stream()
498+
.noneMatch(chk -> StringUtils.containsIgnoreCase(value, chk))) {
499+
return false;
500+
}
501+
Set<String> extractedComments = extractJsComments(value);
502+
String[] valueWithoutComments = {value};
503+
extractedComments.forEach(
504+
comment -> valueWithoutComments[0] = valueWithoutComments[0].replace(comment, ""));
505+
506+
Matcher matcher = pattern.matcher(valueWithoutComments[0]);
507+
493508
return matcher.find()
494509
&& StringUtils.startsWithIgnoreCase(matcher.group(1), HttpHeader.HTTP);
495510
}
496511

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

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/ java 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)