Skip to content

Commit a3c7e33

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

File tree

3 files changed

+133
-26
lines changed

3 files changed

+133
-26
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: 91 additions & 26 deletions
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;
@@ -116,6 +118,27 @@ public String getReason() {
116118
private static final String SITE_HOST =
117119
Long.toString(Math.abs(UUID.randomUUID().getMostSignificantBits()));
118120
private static final String REDIRECT_SITE = SITE_HOST + OWASP_SUFFIX;
121+
private static final String URL_PATTERN = "https?://" + REDIRECT_SITE;
122+
123+
// location='http://evil.com/';
124+
// location.href='http://evil.com/';
125+
private static final Pattern LOCATION_SIMPLE = Pattern.compile(
126+
"(?i)location(?:\\.href)?\\s*=\\s*['\"](" + URL_PATTERN + ")['\"]");
127+
128+
// location.reload('http://evil.com/');
129+
// location.replace('http://evil.com/');
130+
// location.assign('http://evil.com/');
131+
private static final Pattern LOCATION_METHODS = Pattern.compile(
132+
"(?i)location\\.(?:replace|reload|assign)\\s*\\(\\s*['\"]("
133+
+ URL_PATTERN
134+
+ ")['\"]");
135+
136+
// window.open('http://evil.com/');
137+
// window.navigate('http://evil.com/');
138+
private static final Pattern WINDOW_METHODS = Pattern.compile(
139+
"(?i)window\\.(?:open|navigate)\\s*\\(\\s*['\"]("
140+
+ URL_PATTERN
141+
+ ")['\"]");
119142

120143
/** The various (prioritized) payload to try */
121144
private enum RedirectPayloads {
@@ -445,41 +468,20 @@ private static RedirectType isRedirected(String payload, HttpMessage msg) {
445468
// http://code.google.com/p/html5security/wiki/RedirectionMethods
446469
if (StringUtils.indexOfIgnoreCase(content, payload) != -1) {
447470
List<Element> jsElements = htmlSrc.getAllElements(HTMLElementName.SCRIPT);
448-
String matchingUrl = "https?://" + REDIRECT_SITE;
449-
Pattern pattern;
450471

451472
for (Element el : jsElements) {
452473
value = el.getContent().toString();
453474

454-
// location='http://evil.com/';
455-
// location.href='http://evil.com/';
456-
pattern =
457-
Pattern.compile(
458-
"(?i)location(?:\\.href)?\\s*=\\s*['\"](" + matchingUrl + ")['\"]");
459-
if (isRedirectPresent(pattern, value)) {
475+
if (isRedirectPresent(LOCATION_SIMPLE, value)) {
460476
return RedirectType.JAVASCRIPT;
461477
}
462478

463-
// location.reload('http://evil.com/');
464-
// location.replace('http://evil.com/');
465-
// location.assign('http://evil.com/');
466-
pattern =
467-
Pattern.compile(
468-
"(?i)location\\.(?:replace|reload|assign)\\s*\\(\\s*['\"]("
469-
+ matchingUrl
470-
+ ")['\"]");
471-
if (isRedirectPresent(pattern, value)) {
479+
480+
if (isRedirectPresent(LOCATION_METHODS, value)) {
472481
return RedirectType.JAVASCRIPT;
473482
}
474483

475-
// window.open('http://evil.com/');
476-
// window.navigate('http://evil.com/');
477-
pattern =
478-
Pattern.compile(
479-
"(?i)window\\.(?:open|navigate)\\s*\\(\\s*['\"]("
480-
+ matchingUrl
481-
+ ")['\"]");
482-
if (isRedirectPresent(pattern, value)) {
484+
if (isRedirectPresent(WINDOW_METHODS, value)) {
483485
return RedirectType.JAVASCRIPT;
484486
}
485487
}
@@ -489,11 +491,74 @@ 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 jsCode) {
506+
Set<String> comments = new HashSet<>();
507+
boolean inSingleQuote = false;
508+
boolean inDoubleQuote = false;
509+
boolean inEscape = false;
510+
511+
for (int i = 0; i < jsCode.length(); i++) {
512+
char c = jsCode.charAt(i);
513+
514+
if (inEscape) {
515+
inEscape = false;
516+
continue;
517+
}
518+
519+
if (c == '\\') {
520+
inEscape = true;
521+
continue;
522+
}
523+
524+
if (c == '\'' && !inDoubleQuote) {
525+
inSingleQuote = !inSingleQuote;
526+
continue;
527+
}
528+
529+
if (c == '"' && !inSingleQuote) {
530+
inDoubleQuote = !inDoubleQuote;
531+
continue;
532+
}
533+
534+
if (!inSingleQuote && !inDoubleQuote) {
535+
// Safe to check for comments
536+
if (c == '/' && i + 1 < jsCode.length()) {
537+
if (jsCode.charAt(i + 1) == '/') {
538+
// Single-line comment
539+
int end = jsCode.indexOf('\n', i + 2);
540+
if (end == -1) {
541+
end = jsCode.length();
542+
}
543+
comments.add(jsCode.substring(i, end));
544+
i = end - 1;
545+
continue;
546+
} else if (jsCode.charAt(i + 1) == '*') {
547+
// Multi-line comment
548+
int end = jsCode.indexOf("*/", i + 2);
549+
if (end == -1) {
550+
end = jsCode.length() - 2;
551+
}
552+
comments.add(jsCode.substring(i, end + 2));
553+
i = end + 1;
554+
continue;
555+
}
556+
}
557+
}
558+
}
559+
return comments;
560+
}
561+
497562
/**
498563
* Give back the risk associated to this vulnerability (high)
499564
*

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)