From 1c3bb4635aed2f5a1d2f2c561af88e05df53cf8c Mon Sep 17 00:00:00 2001 From: forgedhallpass <13679401+forgedhallpass@users.noreply.github.com> Date: Fri, 20 Nov 2020 21:14:33 +0200 Subject: [PATCH] [#146] Issue with the token-per-page support for REST endpoint containing path parameters [#140] Improve the test coverage * Fixed the regex and path matching support * Extension and partial path matching for page tokens is not supported properly yet. ** Every resource will be assigned a new unique token instead of using the defined resource matcher token. ** Although this is not a security issue, in case of a large REST application it can have an impact on performance. ** As a workaround regexes can be used instead. * CSRF validation has been extracted to a separate class * Added unit tests for resource matching (introduced Mockito dependency) * Removed arrow functions from JS because IE does not support it * Added debug statements in the JS code --- csrfguard/pom.xml | 9 + .../java/org/owasp/csrfguard/CsrfGuard.java | 226 ++++------------ .../org/owasp/csrfguard/CsrfGuardFilter.java | 7 +- .../org/owasp/csrfguard/CsrfValidator.java | 247 ++++++++++++++++++ .../org/owasp/csrfguard/ProtectionResult.java | 48 ++++ .../PropertiesConfigurationProvider.java | 22 +- .../http/InterceptRedirectResponse.java | 5 +- .../csrfguard/servlet/JavaScriptServlet.java | 3 +- .../csrfguard/token/service/TokenService.java | 17 +- .../owasp/csrfguard/util/CsrfGuardUtils.java | 24 -- csrfguard/src/main/resources/csrfguard.js | 70 +++-- .../owasp/csrfguard/CsrfValidatorTest.java | 180 +++++++++++++ pom.xml | 16 +- 13 files changed, 638 insertions(+), 236 deletions(-) create mode 100644 csrfguard/src/main/java/org/owasp/csrfguard/CsrfValidator.java create mode 100644 csrfguard/src/main/java/org/owasp/csrfguard/ProtectionResult.java create mode 100644 csrfguard/src/test/java/org/owasp/csrfguard/CsrfValidatorTest.java diff --git a/csrfguard/pom.xml b/csrfguard/pom.xml index 883e48b..f7479b8 100644 --- a/csrfguard/pom.xml +++ b/csrfguard/pom.xml @@ -37,6 +37,15 @@ org.junit.jupiter junit-jupiter-engine + + + org.mockito + mockito-junit-jupiter + + + org.mockito + mockito-inline + diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/CsrfGuard.java b/csrfguard/src/main/java/org/owasp/csrfguard/CsrfGuard.java index bc89847..5d695a5 100644 --- a/csrfguard/src/main/java/org/owasp/csrfguard/CsrfGuard.java +++ b/csrfguard/src/main/java/org/owasp/csrfguard/CsrfGuard.java @@ -37,19 +37,12 @@ import org.owasp.csrfguard.config.overlay.ExpirableCache; import org.owasp.csrfguard.config.properties.ConfigParameters; import org.owasp.csrfguard.log.ILogger; -import org.owasp.csrfguard.log.LogLevel; -import org.owasp.csrfguard.servlet.JavaScriptServlet; import org.owasp.csrfguard.session.LogicalSession; -import org.owasp.csrfguard.token.businessobject.TokenBO; -import org.owasp.csrfguard.token.mapper.TokenMapper; import org.owasp.csrfguard.token.service.TokenService; import org.owasp.csrfguard.token.storage.LogicalSessionExtractor; import org.owasp.csrfguard.token.storage.TokenHolder; -import org.owasp.csrfguard.token.transferobject.TokenTO; import org.owasp.csrfguard.util.CsrfGuardPropertiesToStringBuilder; import org.owasp.csrfguard.util.CsrfGuardUtils; -import org.owasp.csrfguard.util.MessageConstants; -import org.owasp.csrfguard.util.RegexValidationUtil; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -57,10 +50,9 @@ import java.security.SecureRandom; import java.time.Duration; import java.util.*; -import java.util.function.Predicate; import java.util.regex.Pattern; -public final class CsrfGuard { +public class CsrfGuard { /** * cache the configuration for a minute @@ -85,6 +77,10 @@ public static void load(final Properties theProperties) { configurationProviderExpirableCache.clear(); } + public Map getRegexPatternCache() { + return this.regexPatternCache; + } + public ILogger getLogger() { return config().getLogger(); } @@ -244,20 +240,45 @@ public TokenService getTokenService() { return new TokenService(this); } - public boolean isValidRequest(final HttpServletRequest request, final HttpServletResponse response) { - final boolean isValid; + public boolean isPrintConfig() { + return config().isPrintConfig(); + } - final ILogger logger = getLogger(); - final String normalizedResourceURI = CsrfGuardUtils.normalizeResourceURI(request); - if (isProtectedPageAndMethod(request)) { - logger.log(LogLevel.Debug, String.format("CSRFGuard analyzing protected resource: '%s'", normalizedResourceURI)); - isValid = isTokenValidInRequest(request, response); - } else { - logger.log(LogLevel.Debug, String.format("Unprotected page: %s", normalizedResourceURI)); - isValid = true; - } + public String getDomainOrigin() { + return config().getDomainOrigin(); + } + + public Duration getPageTokenSynchronizationTolerance() { + return config().getPageTokenSynchronizationTolerance(); + } + + /** + * if there are methods specified, then they (e.g. GET) are unprotected, and all others are protected + * + * @return the unprotected HTTP methods + */ + public Set getUnprotectedMethods() { + return config().getUnprotectedMethods(); + } - return isValid; + @Override + public String toString() { + return isEnabled() ? new CsrfGuardPropertiesToStringBuilder(config()).toString() + : "OWASP CSRFGuard is disabled."; + } + + /** + * Rotation in case of AJAX requests is not currently not supported because of the possible race conditions. + *

+ * A Single Page Application can fire multiple simultaneous requests. + * If rotation is enabled, the first request might trigger a token change before the validation of the second request with the same token, causing + * false-positive CSRF intrusion exceptions. + * + * @param request the current request + * @return True if rotation is enabled and possible + */ + public boolean isRotateEnabled(final HttpServletRequest request) { + return isRotateEnabled() && !CsrfGuardUtils.isAjaxRequest(request); } /** @@ -276,6 +297,7 @@ public void onSessionCreated(final LogicalSession logicalSession) { if (isTokenPerPageEnabled() && isTokenPerPagePrecreate() + && isProtectEnabled() && !logicalSession.areTokensGenerated()) { tokenService.generateProtectedPageTokens(logicalSessionKey); @@ -323,7 +345,7 @@ public void writeLandingPage(final HttpServletRequest request, final HttpServlet .append("\");"); /* only include token if needed */ - if (isProtectedPage(landingPage)) { + if (new CsrfValidator().isProtectedPage(landingPage).isProtected()) { stringBuilder.append("var hiddenField = document.createElement(\"input\");") .append("hiddenField.setAttribute(\"type\", \"hidden\");") .append("hiddenField.setAttribute(\"name\", \"") @@ -351,147 +373,6 @@ public void writeLandingPage(final HttpServletRequest request, final HttpServlet response.getWriter().write(code); } - public boolean isProtectedPageAndMethod(final String page, final String method) { - return (isProtectedPage(CsrfGuardUtils.normalizeResourceURI(page)) && isProtectedMethod(method)); - } - - public boolean isProtectedPageAndMethod(final HttpServletRequest request) { - return isProtectedPageAndMethod(request.getRequestURI(), request.getMethod()); - } - - public boolean isPrintConfig() { - return config().isPrintConfig(); - } - - public String getDomainOrigin() { - return config().getDomainOrigin(); - } - - public Duration getPageTokenSynchronizationTolerance() { - return config().getPageTokenSynchronizationTolerance(); - } - - /** - * if there are methods specified, then they (e.g. GET) are unprotected, and all others are protected - * - * @return the unprotected HTTP methods - */ - public Set getUnprotectedMethods() { - return config().getUnprotectedMethods(); - } - - @Override - public String toString() { - return isEnabled() ? new CsrfGuardPropertiesToStringBuilder(config()).toString() - : "OWASP CSRFGuard is disabled."; - } - - private static boolean isUriPathMatch(final String testPath, final String requestPath) { - return testPath.equals("/*") || (testPath.endsWith("/*") - && (testPath.regionMatches(0, requestPath, 0, testPath.length() - 2) - && (requestPath.length() == (testPath.length() - 2) || '/' == requestPath.charAt(testPath.length() - 2)))); - } - - private boolean isTokenValidInRequest(final HttpServletRequest request, final HttpServletResponse response) { - boolean isValid = false; - - final CsrfGuard csrfGuard = CsrfGuard.getInstance(); - final LogicalSession logicalSession = csrfGuard.getLogicalSessionExtractor().extract(request); - - if (Objects.nonNull(logicalSession)) { - final TokenService tokenService = getTokenService(); - final String logicalSessionKey = logicalSession.getKey(); - final String masterToken = tokenService.getMasterToken(logicalSessionKey); - - if (Objects.nonNull(masterToken)) { - try { - final TokenBO tokenBO = tokenService.verifyToken(request, logicalSessionKey, masterToken); - - final TokenTO tokenTO = isRotateEnabled(request) ? tokenService.rotateUsedToken(logicalSessionKey, request.getRequestURI(), tokenBO) - : TokenMapper.toTransferObject(tokenBO); - - CsrfGuardUtils.addResponseTokenHeader(csrfGuard, request, response, tokenTO); - - isValid = true; - } catch (final CsrfGuardException csrfe) { - callActionsOnError(request, response, csrfe); - } - } else { - callActionsOnError(request, response, new CsrfGuardException(MessageConstants.TOKEN_MISSING_FROM_STORAGE_MSG)); - } - } else { - callActionsOnError(request, response, new CsrfGuardException(MessageConstants.TOKEN_MISSING_FROM_STORAGE_MSG)); - } - - return isValid; - } - - /** - * Rotation in case of AJAX requests is not currently not supported because of the possible race conditions. - *

- * A Single Page Application can fire multiple simultaneous requests. - * If rotation is enabled, the first request might trigger a token change before the validation of the second request with the same token, causing - * false-positive CSRF intrusion exceptions. - * - * @param request the current request - * @return True if rotation is enabled and possible - */ - private boolean isRotateEnabled(final HttpServletRequest request) { - return isRotateEnabled() && !CsrfGuardUtils.isAjaxRequest(request); - } - - private boolean isProtectedPage(final String normalizedResourceUri) { - /* if this is a javascript page, let it go through */ - if (JavaScriptServlet.getJavascriptUris().contains(normalizedResourceUri)) { - return false; - } - - final Predicate predicate = page -> isUriMatch(page, normalizedResourceUri); - return isProtectEnabled() ? getProtectedPages().stream().anyMatch(predicate) /* all links are unprotected, except the ones that were explicitly specified */ - : getUnprotectedPages().stream().noneMatch(predicate); /* all links are protected, except the ones were explicitly excluded */ - } - - /** - * Whether or not the HTTP method is protected, i.e. should be checked for token. - * - * @param method The method to check for protection status - * @return true when the given method name is in the protected methods set and not in the unprotected methods set - */ - private boolean isProtectedMethod(final String method) { - boolean isProtected = true; - - final Set protectedMethods = getProtectedMethods(); - if (!protectedMethods.isEmpty() && !protectedMethods.contains(method)) { - isProtected = false; - } - - final Set unprotectedMethods = getUnprotectedMethods(); - if (!unprotectedMethods.isEmpty() && unprotectedMethods.contains(method)) { - isProtected = false; - } - - return isProtected; - } - - /** - * FIXME: partially taken from Tomcat - ApplicationFilterFactory#matchFiltersURL - * - * @param testPath the pattern to match. - * @param requestPath the current request path. - * @return {@code true} if {@code requestPath} matches {@code testPath}. - */ - private boolean isUriMatch(final String testPath, final String requestPath) { - return Objects.nonNull(testPath) && (testPath.equals(requestPath) - || isUriPathMatch(testPath, requestPath) - || CsrfGuardUtils.isExtensionMatch(testPath, requestPath) - || isUriRegexMatch(testPath, requestPath)); - } - - private boolean isUriRegexMatch(final String testPath, final String requestPath) { - return RegexValidationUtil.isTestPathRegex(testPath) && this.regexPatternCache.computeIfAbsent(testPath, k -> Pattern.compile(testPath)) - .matcher(requestPath) - .matches(); - } private ConfigurationProvider config() { if (this.properties == null) { @@ -530,25 +411,6 @@ private ConfigurationProvider retrieveNewConfig() { return configurationProvider; } - /** - * Invoked when there was a CsrfGuardException such as a token mismatch error. - * Calls the configured actions. - * - * @param request The HttpServletRequest - * @param response The HttpServletResponse - * @param csrfe The exception that triggered the actions call. Passed to the action. - * @see IAction#execute(HttpServletRequest, HttpServletResponse, CsrfGuardException, CsrfGuard) - */ - private void callActionsOnError(final HttpServletRequest request, final HttpServletResponse response, final CsrfGuardException csrfe) { - for (final IAction action : getActions()) { - try { - action.execute(request, response, csrfe, this); - } catch (final CsrfGuardException exception) { - getLogger().log(LogLevel.Error, exception); - } - } - } - private static final class SingletonHolder { public static final CsrfGuard instance = new CsrfGuard(); } diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/CsrfGuardFilter.java b/csrfguard/src/main/java/org/owasp/csrfguard/CsrfGuardFilter.java index 20d9489..125e5b1 100644 --- a/csrfguard/src/main/java/org/owasp/csrfguard/CsrfGuardFilter.java +++ b/csrfguard/src/main/java/org/owasp/csrfguard/CsrfGuardFilter.java @@ -48,7 +48,7 @@ public final class CsrfGuardFilter implements Filter { private FilterConfig filterConfig = null; @Override - public void init(final FilterConfig filterConfig) throws ServletException { + public void init(final FilterConfig filterConfig) { this.filterConfig = filterConfig; } @@ -92,12 +92,13 @@ private void handleSession(final HttpServletRequest httpServletRequest, final In if (logicalSession.isNew() && csrfGuard.isUseNewTokenLandingPage()) { csrfGuard.writeLandingPage(httpServletRequest, interceptRedirectResponse, logicalSessionKey); - } else if (csrfGuard.isValidRequest(httpServletRequest, interceptRedirectResponse)) { + } else if (new CsrfValidator().isValid(httpServletRequest, interceptRedirectResponse)) { filterChain.doFilter(httpServletRequest, interceptRedirectResponse); } else { logInvalidRequest(httpServletRequest, csrfGuard); } + // TODO this is not needed in case of un-protected pages final String requestURI = httpServletRequest.getRequestURI(); final String generatedToken = csrfGuard.getTokenService().generateTokensIfAbsent(logicalSessionKey, httpServletRequest.getMethod(), requestURI); @@ -107,7 +108,7 @@ private void handleSession(final HttpServletRequest httpServletRequest, final In private void handleNoSession(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final InterceptRedirectResponse interceptRedirectResponse, final FilterChain filterChain, final CsrfGuard csrfGuard) throws IOException, ServletException { if (csrfGuard.isValidateWhenNoSessionExists()) { - if (csrfGuard.isValidRequest(httpServletRequest, interceptRedirectResponse)) { + if (new CsrfValidator().isValid(httpServletRequest, interceptRedirectResponse)) { filterChain.doFilter(httpServletRequest, interceptRedirectResponse); } else { logInvalidRequest(httpServletRequest, csrfGuard); diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/CsrfValidator.java b/csrfguard/src/main/java/org/owasp/csrfguard/CsrfValidator.java new file mode 100644 index 0000000..9b2208d --- /dev/null +++ b/csrfguard/src/main/java/org/owasp/csrfguard/CsrfValidator.java @@ -0,0 +1,247 @@ +/* + * The OWASP CSRFGuard Project, BSD License + * Copyright (c) 2011, Eric Sheridan (eric@infraredsecurity.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of OWASP nor the names of its contributors may be used + * to endorse or promote products derived from this software without specific + * prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.owasp.csrfguard; + +import org.apache.commons.lang3.StringUtils; +import org.owasp.csrfguard.action.IAction; +import org.owasp.csrfguard.log.ILogger; +import org.owasp.csrfguard.log.LogLevel; +import org.owasp.csrfguard.servlet.JavaScriptServlet; +import org.owasp.csrfguard.session.LogicalSession; +import org.owasp.csrfguard.token.businessobject.TokenBO; +import org.owasp.csrfguard.token.mapper.TokenMapper; +import org.owasp.csrfguard.token.service.TokenService; +import org.owasp.csrfguard.token.transferobject.TokenTO; +import org.owasp.csrfguard.util.CsrfGuardUtils; +import org.owasp.csrfguard.util.MessageConstants; +import org.owasp.csrfguard.util.RegexValidationUtil; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Objects; +import java.util.Set; +import java.util.function.UnaryOperator; +import java.util.regex.Pattern; + +public final class CsrfValidator { + + private final CsrfGuard csrfGuard; + + public CsrfValidator() { + this.csrfGuard = CsrfGuard.getInstance(); + } + + public boolean isValid(final HttpServletRequest request, final HttpServletResponse response) { + final boolean isValid; + + final ILogger logger = this.csrfGuard.getLogger(); + final String normalizedResourceURI = CsrfGuardUtils.normalizeResourceURI(request); + final ProtectionResult protectionResult = isProtectedPageAndMethod(request); + if (protectionResult.isProtected()) { + logger.log(LogLevel.Debug, String.format("CSRFGuard analyzing protected resource: '%s'", normalizedResourceURI)); + isValid = isTokenValidInRequest(request, response, protectionResult.getResourceIdentifier()); + } else { + logger.log(LogLevel.Debug, String.format("Unprotected page: %s", normalizedResourceURI)); + isValid = true; + } + + return isValid; + } + + public ProtectionResult isProtectedPageAndMethod(final String page, final String method) { + final String normalizedResourceUri = CsrfGuardUtils.normalizeResourceURI(page); + final ProtectionResult protectionResult = isProtectedPage(normalizedResourceUri); + + return (protectionResult.isProtected() && isProtectedMethod(method)) ? protectionResult + : new ProtectionResult(false, normalizedResourceUri); + } + + ProtectionResult isProtectedPage(final String normalizedResourceUri) { + final ProtectionResult protectionResult; + + if (JavaScriptServlet.getJavascriptUris().contains(normalizedResourceUri)) { + /* if this is a javascript page, let it go through */ + protectionResult = new ProtectionResult(false, normalizedResourceUri); + } else if (this.csrfGuard.isProtectEnabled()) { + /* all links are unprotected, except the ones that were explicitly specified */ + protectionResult = isUriMatch(normalizedResourceUri, this.csrfGuard.getProtectedPages(), v -> v, false); + } else { + /* all links are protected, except the ones were explicitly excluded */ + protectionResult = isUriMatch(normalizedResourceUri, this.csrfGuard.getUnprotectedPages(), v -> new ProtectionResult(false, v.getResourceIdentifier()), true); + } + return protectionResult; + } + + private static boolean isUriPathMatch(final String configuredPageUri, final String requestUri) { + return configuredPageUri.equals("/*") || (configuredPageUri.endsWith("/*") && (configuredPageUri.regionMatches(0, requestUri, 0, configuredPageUri.length() - 2) + && ((requestUri.length() == (configuredPageUri.length() - 2)) || ('/' == requestUri.charAt(configuredPageUri.length() - 2))))); + } + + /** + * FIXME: taken from Tomcat - ApplicationFilterFactory#matchFiltersURL + */ + private static boolean isExtensionMatch(final String testPath, final String requestPath) { + final boolean result; + if (StringUtils.startsWith(testPath, "*.")) { + final int slash = requestPath.lastIndexOf('/'); + final int period = requestPath.lastIndexOf('.'); + + if ((slash >= 0) + && (period > slash) + && (period != requestPath.length() - 1) + && ((requestPath.length() - period) == (testPath.length() - 1))) { + result = testPath.regionMatches(2, requestPath, period + 1, testPath.length() - 2); + } else { + result = false; + } + } else { + result = false; + } + + return result; + } + + private ProtectionResult isUriMatch(final String normalizedResourceUri, final Set pages, final UnaryOperator operator, final boolean isProtected) { + for (final String page : pages) { + final ProtectionResult protectionResult = isUriMatch(page, normalizedResourceUri); + if (protectionResult.isProtected()) { + return operator.apply(protectionResult); + } + } + return new ProtectionResult(isProtected, normalizedResourceUri); + } + + private TokenService getTokenService() { + return new TokenService(this.csrfGuard); + } + + private ProtectionResult isProtectedPageAndMethod(final HttpServletRequest request) { + return isProtectedPageAndMethod(request.getRequestURI(), request.getMethod()); + } + + /** + * Whether or not the HTTP method is protected, i.e. should be checked for token. + * + * @param method The method to check for protection status + * @return true when the given method name is in the protected methods set and not in the unprotected methods set + */ + private boolean isProtectedMethod(final String method) { + boolean isProtected = true; + + final Set protectedMethods = this.csrfGuard.getProtectedMethods(); + if (!protectedMethods.isEmpty() && !protectedMethods.contains(method)) { + isProtected = false; + } + + final Set unprotectedMethods = this.csrfGuard.getUnprotectedMethods(); + if (!unprotectedMethods.isEmpty() && unprotectedMethods.contains(method)) { + isProtected = false; + } + + return isProtected; + } + + /** + * @param configuredPageUri the pattern to match. + * @param requestUri the current request path. + * @return {@code true} if {@code requestUri} matches {@code configuredPageUri}. + */ + private ProtectionResult isUriMatch(final String configuredPageUri, final String requestUri) { + if (Objects.nonNull(configuredPageUri)) { + if (configuredPageUri.equals(requestUri) || isUriPathMatch(configuredPageUri, requestUri) || isExtensionMatch(configuredPageUri, requestUri)) { + return new ProtectionResult(true, requestUri); + } else if (isUriRegexMatch(configuredPageUri, requestUri)) { + return new ProtectionResult(true, configuredPageUri); + } else { + return new ProtectionResult(false, requestUri); + } + } else { + return new ProtectionResult(false, requestUri); + } + } + + private boolean isUriRegexMatch(final String configuredPageUri, final String requestUri) { + return RegexValidationUtil.isTestPathRegex(configuredPageUri) && this.csrfGuard.getRegexPatternCache().computeIfAbsent(configuredPageUri, k -> Pattern.compile(configuredPageUri)) + .matcher(requestUri) + .matches(); + } + + private boolean isTokenValidInRequest(final HttpServletRequest request, final HttpServletResponse response, final String resourceIdentifier) { + boolean isValid = false; + + final CsrfGuard csrfGuard = CsrfGuard.getInstance(); + final LogicalSession logicalSession = csrfGuard.getLogicalSessionExtractor().extract(request); + + if (Objects.nonNull(logicalSession)) { + final TokenService tokenService = getTokenService(); + final String logicalSessionKey = logicalSession.getKey(); + final String masterToken = tokenService.getMasterToken(logicalSessionKey); + + if (Objects.nonNull(masterToken)) { + try { + final TokenBO tokenBO = tokenService.verifyToken(request, resourceIdentifier, logicalSessionKey, masterToken); + + final TokenTO tokenTO = csrfGuard.isRotateEnabled(request) ? tokenService.rotateUsedToken(logicalSessionKey, resourceIdentifier, tokenBO) + : TokenMapper.toTransferObject(tokenBO); + + CsrfGuardUtils.addResponseTokenHeader(csrfGuard, request, response, tokenTO); + + isValid = true; + } catch (final CsrfGuardException e) { + callActionsOnError(request, response, e); + } + } else { + callActionsOnError(request, response, new CsrfGuardException(MessageConstants.TOKEN_MISSING_FROM_STORAGE_MSG)); + } + } else { + callActionsOnError(request, response, new CsrfGuardException(MessageConstants.TOKEN_MISSING_FROM_STORAGE_MSG)); + } + + return isValid; + } + + /** + * Invoked when there was a CsrfGuardException such as a token mismatch error. + * Calls the configured actions. + * + * @param request The HttpServletRequest + * @param response The HttpServletResponse + * @param csrfGuardException The exception that triggered the actions call. Passed to the action. + * @see IAction#execute(HttpServletRequest, HttpServletResponse, CsrfGuardException, CsrfGuard) + */ + private void callActionsOnError(final HttpServletRequest request, final HttpServletResponse response, final CsrfGuardException csrfGuardException) { + for (final IAction action : this.csrfGuard.getActions()) { + try { + action.execute(request, response, csrfGuardException, this.csrfGuard); + } catch (final CsrfGuardException exception) { + this.csrfGuard.getLogger().log(LogLevel.Error, exception); + } + } + } +} diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/ProtectionResult.java b/csrfguard/src/main/java/org/owasp/csrfguard/ProtectionResult.java new file mode 100644 index 0000000..47ca270 --- /dev/null +++ b/csrfguard/src/main/java/org/owasp/csrfguard/ProtectionResult.java @@ -0,0 +1,48 @@ +/* + * The OWASP CSRFGuard Project, BSD License + * Copyright (c) 2011, Eric Sheridan (eric@infraredsecurity.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of OWASP nor the names of its contributors may be used + * to endorse or promote products derived from this software without specific + * prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.owasp.csrfguard; + +public class ProtectionResult { + + private final boolean isProtected; + private final String resourceIdentifier; + + public ProtectionResult(final boolean isProtected, final String resourceIdentifier) { + this.isProtected = isProtected; + this.resourceIdentifier = resourceIdentifier; + } + + public boolean isProtected() { + return this.isProtected; + } + + public String getResourceIdentifier() { + return this.resourceIdentifier; + } +} diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/config/PropertiesConfigurationProvider.java b/csrfguard/src/main/java/org/owasp/csrfguard/config/PropertiesConfigurationProvider.java index 2a70469..4d70171 100644 --- a/csrfguard/src/main/java/org/owasp/csrfguard/config/PropertiesConfigurationProvider.java +++ b/csrfguard/src/main/java/org/owasp/csrfguard/config/PropertiesConfigurationProvider.java @@ -43,6 +43,7 @@ import org.owasp.csrfguard.token.storage.LogicalSessionExtractor; import org.owasp.csrfguard.token.storage.TokenHolder; import org.owasp.csrfguard.util.CsrfGuardUtils; +import org.owasp.csrfguard.util.RegexValidationUtil; import javax.servlet.ServletConfig; import java.io.IOException; @@ -507,19 +508,36 @@ private static Pair getPropertyDirective(final String propertyK return result; } - private static String getPageProperty(final Properties properties, final String propertyKey, final String propertyKeyPrefix) { + private String getPageProperty(final Properties properties, final String propertyKey, final String propertyKeyPrefix) { String result = null; final String pageProperty = getPrimaryPropertyDirective(propertyKey, propertyKeyPrefix); if (Objects.nonNull(pageProperty)) { final String pageUri = PropertyUtils.getProperty(properties, propertyKey); - result = CsrfGuardUtils.normalizeResourceURI(pageUri); + result = isSpecialUriDescriptor(pageUri) ? pageUri + : CsrfGuardUtils.normalizeResourceURI(pageUri); } return result; } + /** + * Decides whether the given resourceUri is a static descriptor or a matching rule + * Has to be in sync with org.owasp.csrfguard.CsrfValidator.isUriMatch(java.lang.String, java.lang.String) and calculatePageTokenForUri in the JS logic + */ + private boolean isSpecialUriDescriptor(final String resourceUri) { + if (this.tokenPerPage && (resourceUri.endsWith("/*") || resourceUri.startsWith("*."))) { + // FIXME implement in the JS logic (calculatePageTokenForUri) + this.logger.log(LogLevel.Warning, "'Extension' and 'partial path wildcard' matching for page tokens is not supported properly yet! " + + "Every resource will be assigned a new unique token instead of using the defined resource matcher token. " + + "Although this is not a security issue, in case of a large REST application it can have an impact on performance." + + "Consider using regular expressions instead."); + } + + return RegexValidationUtil.isTestPathRegex(resourceUri) || resourceUri.startsWith("/*") || resourceUri.endsWith("/*") || resourceUri.startsWith("*."); + } + private void javascriptInitParamsIfNeeded() { if (!this.javascriptParamsInitialized) { final ServletConfig servletConfig = JavaScriptServlet.getStaticServletConfig(); diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/http/InterceptRedirectResponse.java b/csrfguard/src/main/java/org/owasp/csrfguard/http/InterceptRedirectResponse.java index 1fd89b9..be04200 100644 --- a/csrfguard/src/main/java/org/owasp/csrfguard/http/InterceptRedirectResponse.java +++ b/csrfguard/src/main/java/org/owasp/csrfguard/http/InterceptRedirectResponse.java @@ -30,6 +30,8 @@ package org.owasp.csrfguard.http; import org.owasp.csrfguard.CsrfGuard; +import org.owasp.csrfguard.CsrfValidator; +import org.owasp.csrfguard.ProtectionResult; import org.owasp.csrfguard.session.LogicalSession; import org.owasp.csrfguard.token.service.TokenService; @@ -60,7 +62,8 @@ public void sendRedirect(final String location) throws IOException { final String sanitizedLocation = location.replaceAll("(\\r|\\n|%0D|%0A|%0a|%0d)", ""); /* ensure token included in redirects */ - if (!sanitizedLocation.contains("://") && this.csrfGuard.isProtectedPageAndMethod(sanitizedLocation, "GET")) { + final ProtectionResult protectionResult = new CsrfValidator().isProtectedPageAndMethod(sanitizedLocation, "GET"); + if (!sanitizedLocation.contains("://") && protectionResult.isProtected()) { // Separate URL fragment from path, e.g. /myPath#myFragment becomes [0]: /myPath [1]: myFragment final String[] splitOnFragment = location.split("#", 2); diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/servlet/JavaScriptServlet.java b/csrfguard/src/main/java/org/owasp/csrfguard/servlet/JavaScriptServlet.java index 1445be2..ce5f46b 100644 --- a/csrfguard/src/main/java/org/owasp/csrfguard/servlet/JavaScriptServlet.java +++ b/csrfguard/src/main/java/org/owasp/csrfguard/servlet/JavaScriptServlet.java @@ -33,6 +33,7 @@ import org.apache.commons.lang3.StringUtils; import org.owasp.csrfguard.CsrfGuard; import org.owasp.csrfguard.CsrfGuardServletContextListener; +import org.owasp.csrfguard.CsrfValidator; import org.owasp.csrfguard.log.LogLevel; import org.owasp.csrfguard.session.LogicalSession; import org.owasp.csrfguard.token.storage.LogicalSessionExtractor; @@ -142,7 +143,7 @@ public void doGet(final HttpServletRequest request, final HttpServletResponse re public void doPost(final HttpServletRequest request, final HttpServletResponse response) throws IOException { final CsrfGuard csrfGuard = CsrfGuard.getInstance(); - if (csrfGuard.isValidRequest(request, response)) { + if (new CsrfValidator().isValid(request, response)) { if (csrfGuard.isTokenPerPageEnabled()) { // TODO pass the logical session downstream, see whether the null check can be done from here final LogicalSession logicalSession = csrfGuard.getLogicalSessionExtractor().extract(request); diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/token/service/TokenService.java b/csrfguard/src/main/java/org/owasp/csrfguard/token/service/TokenService.java index ec6ff03..9bac95c 100644 --- a/csrfguard/src/main/java/org/owasp/csrfguard/token/service/TokenService.java +++ b/csrfguard/src/main/java/org/owasp/csrfguard/token/service/TokenService.java @@ -30,6 +30,8 @@ import org.owasp.csrfguard.CsrfGuard; import org.owasp.csrfguard.CsrfGuardException; +import org.owasp.csrfguard.CsrfValidator; +import org.owasp.csrfguard.ProtectionResult; import org.owasp.csrfguard.session.LogicalSession; import org.owasp.csrfguard.token.TokenUtils; import org.owasp.csrfguard.token.businessobject.TokenBO; @@ -115,8 +117,9 @@ public String generateTokensIfAbsent(final String logicalSessionKey, final Strin final TokenHolder tokenHolder = this.csrfGuard.getTokenHolder(); if (this.csrfGuard.isTokenPerPageEnabled()) { - if (this.csrfGuard.isProtectedPageAndMethod(requestURI, httpMethod)) { - return tokenHolder.createPageTokenIfAbsent(logicalSessionKey, CsrfGuardUtils.normalizeResourceURI(requestURI), TokenUtils::generateRandomToken); + final ProtectionResult protectionResult = new CsrfValidator().isProtectedPageAndMethod(requestURI, httpMethod); + if (protectionResult.isProtected()) { + return tokenHolder.createPageTokenIfAbsent(logicalSessionKey, protectionResult.getResourceIdentifier(), TokenUtils::generateRandomToken); } } @@ -161,7 +164,6 @@ public void generateProtectedPageTokens(final String logicalSessionKey) { * @return a TokenTO transfer object containing the updated token values that will be sent back to the client */ public TokenTO rotateUsedToken(final String logicalSessionKey, final String requestURI, final TokenBO usedValidToken) { - final String normalizedResourceURI = CsrfGuardUtils.normalizeResourceURI(requestURI); final TokenHolder tokenHolder = this.csrfGuard.getTokenHolder(); final String newTokenValue = TokenUtils.generateRandomToken(); @@ -170,8 +172,8 @@ public TokenTO rotateUsedToken(final String logicalSessionKey, final String requ tokenHolder.setMasterToken(logicalSessionKey, newTokenValue); usedValidToken.setUpdatedMasterToken(newTokenValue); } else { - tokenHolder.setPageToken(logicalSessionKey, normalizedResourceURI, newTokenValue); - usedValidToken.setUpdatedPageToken(normalizedResourceURI, newTokenValue); + tokenHolder.setPageToken(logicalSessionKey, requestURI, newTokenValue); + usedValidToken.setUpdatedPageToken(requestURI, newTokenValue); } return TokenMapper.toTransferObject(usedValidToken); @@ -214,12 +216,13 @@ public String getTokenValue(final String logicalSessionKey, final String resourc *

* * @param request current HTTP Servlet Request + * @param resourceIdentifier the requested resource identifier * @param logicalSessionKey identifies the current logical session uniquely * @param masterToken the master token * @return The TokenBO business object that contains the updated tokens and the token used to validate the current request * @throws CsrfGuardException if the request does not have a valid token associated */ - public TokenBO verifyToken(final HttpServletRequest request, final String logicalSessionKey, final String masterToken) throws CsrfGuardException { + public TokenBO verifyToken(final HttpServletRequest request, final String resourceIdentifier, final String logicalSessionKey, final String masterToken) throws CsrfGuardException { final String tokenName = this.csrfGuard.getTokenName(); final boolean isAjaxRequest = this.csrfGuard.isAjaxEnabled() && CsrfGuardUtils.isAjaxRequest(request); @@ -228,7 +231,7 @@ public TokenBO verifyToken(final HttpServletRequest request, final String logica if (Objects.isNull(tokenFromRequest)) { throw new CsrfGuardException(MessageConstants.REQUEST_MISSING_TOKEN_MSG); } else { - tokenBO = this.csrfGuard.isTokenPerPageEnabled() ? verifyPageToken(logicalSessionKey, masterToken, tokenFromRequest, CsrfGuardUtils.normalizeResourceURI(request), isAjaxRequest) + tokenBO = this.csrfGuard.isTokenPerPageEnabled() ? verifyPageToken(logicalSessionKey, masterToken, tokenFromRequest, resourceIdentifier, isAjaxRequest) : verifyMasterToken(masterToken, tokenFromRequest); } diff --git a/csrfguard/src/main/java/org/owasp/csrfguard/util/CsrfGuardUtils.java b/csrfguard/src/main/java/org/owasp/csrfguard/util/CsrfGuardUtils.java index 1684ef6..0f702c1 100644 --- a/csrfguard/src/main/java/org/owasp/csrfguard/util/CsrfGuardUtils.java +++ b/csrfguard/src/main/java/org/owasp/csrfguard/util/CsrfGuardUtils.java @@ -113,30 +113,6 @@ public static T newInstance(final Class theClass) { return ConfigPropertiesCascadeCommonUtils.newInstance(theClass); } - /** - * FIXME: taken from Tomcat - ApplicationFilterFactory#matchFiltersURL - */ - public static boolean isExtensionMatch(final String testPath, final String requestPath) { - final boolean result; - if (StringUtils.startsWith(testPath, "*.")) { - final int slash = requestPath.lastIndexOf('/'); - final int period = requestPath.lastIndexOf('.'); - - if ((slash >= 0) - && (period > slash) - && (period != requestPath.length() - 1) - && ((requestPath.length() - period) == (testPath.length() - 1))) { - result = testPath.regionMatches(2, requestPath, period + 1, testPath.length() - 2); - } else { - result = false; - } - } else { - result = false; - } - - return result; - } - public static void addResponseTokenHeader(final CsrfGuard csrfGuard, final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final TokenTO tokenTO) { if (csrfGuard.isAjaxEnabled() && CsrfGuardUtils.isAjaxRequest(httpServletRequest)) { if (!tokenTO.isEmpty()) { diff --git a/csrfguard/src/main/resources/csrfguard.js b/csrfguard/src/main/resources/csrfguard.js index 9f7a370..06f2899 100644 --- a/csrfguard/src/main/resources/csrfguard.js +++ b/csrfguard/src/main/resources/csrfguard.js @@ -311,6 +311,30 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { return uri; } + function calculatePageTokenForUri(pageTokens, uri) { + let value = null; + Object.keys(pageTokens).forEach(function (pageTokenKey) { + var pageToken = pageTokens[pageTokenKey]; + + if (uri === pageTokenKey) { + value = pageToken; + } else if (pageTokenKey.startsWith('^') && pageTokenKey.endsWith('$')) { // regex matching + if (new RegExp(pageTokenKey).test(uri)) { + value = pageToken; + } + } else if (pageTokenKey.startsWith('/*')) { // full path wildcard path matching + value = pageToken; + } else if (pageTokenKey.endsWith('/*') || pageTokenKey.startsWith('.*')) { // 'partial path wildcard' and 'extension' matching + // TODO implement + console.warn("'Extension' and 'partial path wildcard' matching for page tokens is not supported properly yet! " + + "Every resource will be assigned a new unique token instead of using the defined resource matcher token. " + + "Although this is not a security issue, in case of a large REST application it can have an impact on performance." + + "Consider using regular expressions instead."); + } + }); + return value; + } + /** * inject tokens as hidden fields into forms */ @@ -327,13 +351,14 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { var value = tokenValue; var action = form.getAttribute('action'); - if (isValidUrl(action)) { - if (action !== null) { - var uri = parseUri(action); - value = pageTokens[uri] != null ? pageTokens[uri] : tokenValue; - } + if (action !== null && isValidUrl(action)) { + var uri = parseUri(action); + const calculatedPageToken = calculatePageTokenForUri(pageTokens, uri); + value = calculatedPageToken == null ? tokenValue : calculatedPageToken; - let hiddenTokenFields = Object.keys(form.elements).filter(i => form.elements[i].name === tokenName); // TODO do not use arrow functions because IE does not support it + let hiddenTokenFields = Object.keys(form.elements).filter(function (i) { + return form.elements[i].name === tokenName; + }); if (hiddenTokenFields.length === 0) { var hidden = document.createElement('input'); @@ -345,7 +370,9 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { form.appendChild(hidden); console.debug('Hidden input element [', hidden, '] was added to the form: ', form); } else { - hiddenTokenFields.forEach(i => form.elements[i].value = value); // TODO do not use arrow functions because IE does not support it + hiddenTokenFields.forEach(function (i) { + return form.elements[i].value = value; + }); console.debug('Hidden token fields [', hiddenTokenFields, '] of form [', form, '] were updated with new token value: ', value); } } @@ -370,7 +397,8 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { if (location != null && isValidUrl(location) && !isUnprotectedExtension(location)) { const uri = parseUri(location); - const value = (pageTokens[uri] != null ? pageTokens[uri] : tokenValue); + const calculatedPageToken = calculatePageTokenForUri(pageTokens, uri); + const value = calculatedPageToken == null ? tokenValue : calculatedPageToken; const tokenValueMatcher = new RegExp('(?:' + tokenName + '=)([^?|#|&]+)', 'gi'); const tokenMatches = tokenValueMatcher.exec(location); @@ -396,7 +424,9 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { } } else { let newLocation = location; - tokenMatches.slice(1).forEach(match => newLocation = newLocation.replace(match, value)); // TODO do not use arrow functions because IE does not support it + tokenMatches.slice(1).forEach(function (match) { + return newLocation = newLocation.replace(match, value); + }); element.setAttribute(attr, newLocation); console.debug('Attribute [', attr, '] with value [', newLocation, '] set for element: ', element); @@ -509,6 +539,7 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { if (xhr.readyState === 4) { if (xhr.status === 200) { let pageTokens = JSON.parse(xhr.responseText)['pageTokens']; + console.debug('Received page tokens: ', pageTokens); callback.call(this, pageTokens); } else { alert(xhr.status + ': CSRF check failed'); @@ -561,6 +592,7 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { if (isValidDomain(document.domain, '%DOMAIN_ORIGIN%')) { var tokenName = '%TOKEN_NAME%'; var masterTokenValue = '%TOKEN_VALUE%'; + console.debug('Master token [' + tokenName + ']: ', masterTokenValue); var isLoadedWrapper = {isDomContentLoaded: false}; @@ -599,11 +631,15 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { let newMasterToken = tokenTO['masterToken']; if (newMasterToken !== undefined) { masterTokenValue = newMasterToken; + console.debug('New master token value received: ', masterTokenValue); } let newPageTokens = tokenTO['pageTokens']; if (newPageTokens !== undefined) { - Object.keys(newPageTokens).forEach(key => pageTokenWrapper.pageTokens[key] = newPageTokens[key]); // TODO do not use arrow functions because IE does not support it + Object.keys(newPageTokens).forEach(function (key) { + return pageTokenWrapper.pageTokens[key] = newPageTokens[key]; + }); + console.debug('New page token value(s) received: ', newPageTokens); } injectTokens(tokenName, masterTokenValue, pageTokenWrapper.pageTokens); @@ -614,16 +650,16 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { } }); - var computePageToken = function(modifiedUrl) { + var computePageToken = function(pageTokens, modifiedUri) { let result = null; let pathWithoutLeadingSlash = window.location.pathname.substring(1); // e.g. deploymentName/service/endpoint let pathArray = pathWithoutLeadingSlash.split('/'); let builtPath = ''; - for (let i = 0; i < pathArray.length - 1; i++) { // the last part of the URL (endpoint) is disregarded because the modifiedUrl parameter is used instead + for (let i = 0; i < pathArray.length - 1; i++) { // the last part of the URI (endpoint) is disregarded because the modifiedUri parameter is used instead builtPath += '/' + pathArray[i]; - let pageTokenValue = pageTokenWrapper.pageTokens[builtPath + modifiedUrl]; + let pageTokenValue = calculatePageTokenForUri(pageTokens, builtPath + modifiedUri); if (pageTokenValue != undefined) { result = pageTokenValue; break; @@ -643,6 +679,10 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { return index > 0 ? currentUrl.substring(0, index) : currentUrl; } + /* + * TODO should other checks be done here like in the isValidUrl? + * Could the url parameter contain full URLs with protocol domain, port etc? + */ let normalizedUrl = url.startsWith('/') ? url : '/' + url; normalizedUrl = removeParameters(normalizedUrl, '?'); @@ -659,9 +699,9 @@ if (owaspCSRFGuardScriptHasLoaded !== true) { if (pageTokenWrapper.pageTokens === null) { this.setRequestHeader(tokenName, masterTokenValue); } else { - let pageToken = pageTokenWrapper.pageTokens[normalizedUrl]; + let pageToken = calculatePageTokenForUri(pageTokenWrapper.pageTokens, normalizedUrl); if (pageToken == undefined) { - let computedPageToken = computePageToken(normalizedUrl); + let computedPageToken = computePageToken(pageTokenWrapper.pageTokens, normalizedUrl); if (computedPageToken === null) { this.setRequestHeader(tokenName, masterTokenValue); diff --git a/csrfguard/src/test/java/org/owasp/csrfguard/CsrfValidatorTest.java b/csrfguard/src/test/java/org/owasp/csrfguard/CsrfValidatorTest.java new file mode 100644 index 0000000..1e40ff9 --- /dev/null +++ b/csrfguard/src/test/java/org/owasp/csrfguard/CsrfValidatorTest.java @@ -0,0 +1,180 @@ +/* + * The OWASP CSRFGuard Project, BSD License + * Copyright (c) 2011, Eric Sheridan (eric@infraredsecurity.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of OWASP nor the names of its contributors may be used + * to endorse or promote products derived from this software without specific + * prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.owasp.csrfguard; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.owasp.csrfguard.servlet.JavaScriptServlet; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CsrfValidatorTest { + + @Test + void testResourceValidationWithNoRules() { + final Set matchingRules = Collections.emptySet(); + final List resourcesToTest = Arrays.asList("test.html", "/test.html"); + + testResourceValidation(matchingRules, Collections.emptyList(), resourcesToTest); + } + + @Test + void testResourceValidationWithPathMatchingRule() { + final Set matchingRules = Collections.singleton("/*"); + + final List resourceSet = Arrays.asList("protect.html", "/protect.html", "something/protect.txt", "/something/protect.txt"); + + testResourceValidation(matchingRules, resourceSet, Collections.emptyList()); + } + + @Test + void testResourceValidationWithStaticRules() { + final Set matchingRules = Stream.of("/unprotected.html", "/also_unprotected.jsp").collect(Collectors.toSet()); + + final List firstResourceSet = Arrays.asList("unprotected.html", "also_unprotected.jsp", "/unprotected.html", "/also_unprotected.jsp"); + final List secondResourceSet = Arrays.asList("test.html", "/test.html", "test.jsp", "/test.jsp"); + + testResourceValidation(matchingRules, firstResourceSet, secondResourceSet); + } + + @Test + void testResourceValidationWithRegexRules() { + final String matchingRule = "^.*protect\\..*$"; + final List firstResourceSet = Arrays.asList("protect.html", "/protect.html", "something/protect.txt", "/something/protect.txt"); + final List secondResourceSet = Arrays.asList("test.html", "/test.html", "/protect"); + + testResourceValidation(matchingRule, firstResourceSet, secondResourceSet); + } + + @Test + void testResourceValidationWithPartialPathMatchingRule() { + final String matchingRule = "/protected/*"; + + final List firstResourceSet = Arrays.asList("protected/test.html", "/protected/test.html"); + final List secondResourceSet = Arrays.asList("test.html", "/test.html", "/protect"); + + testResourceValidation(matchingRule, firstResourceSet, secondResourceSet); + } + + @Test + void testResourceValidationWithExtensionMatchingRule() { + final String matchingRule = "*.jsp"; + + final List firstResourceSet = Arrays.asList("protected/test.jsp", "/protected/test.jsp"); + final List secondResourceSet = Arrays.asList("something/test.html", "/test.html", "/protect"); + + testResourceValidation(matchingRule, firstResourceSet, secondResourceSet); + } + + private static void executeInMockedContext(final Consumer csrfGuardConsumer) { + final CsrfGuard csrfGuard = mock(CsrfGuard.class); + + try (final MockedStatic javaScriptServletMockedStatic = mockStatic(JavaScriptServlet.class)) { + javaScriptServletMockedStatic.when(JavaScriptServlet::getJavascriptUris).thenReturn(Collections.singleton("/JavaScriptServlet")); + + try (final MockedStatic csrfGuardMockedStatic = mockStatic(CsrfGuard.class)) { + csrfGuardMockedStatic.when(CsrfGuard::getInstance).thenReturn(csrfGuard); + csrfGuardConsumer.accept(CsrfGuard.getInstance()); + } + } + } + + private void testResourceValidation(final String matchingRule, final List firstResourceSet, final List secondResourceSet) { + testResourceValidation(Collections.singleton(matchingRule), firstResourceSet, secondResourceSet); + } + + private void testResourceValidation(final Set matchingRule, final List firstResourceSet, final List secondResourceSet) { + testResourceValidation(true, matchingRule, firstResourceSet, secondResourceSet); + testResourceValidation(false, matchingRule, secondResourceSet, firstResourceSet); + } + + private void testResourceValidation(final boolean isProtect, final Set matchingRules, final List protectedResources, final List unProtectedResources) { + executeInMockedContext(csrfGuard -> { + final CsrfValidator csrfValidator = initializeValidator(csrfGuard, isProtect, matchingRules); + + assertPagesAreProtected(csrfValidator, protectedResources); + assertPagesAreNotProtected(csrfValidator, unProtectedResources); + }); + } + + private CsrfValidator initializeValidator(final CsrfGuard csrfGuard, final boolean isProtect, final Set matchingRules) { + when(csrfGuard.isProtectEnabled()).thenReturn(isProtect); + + if (isProtect) { + when(csrfGuard.getProtectedPages()).thenReturn(matchingRules); + } else { + when(csrfGuard.getUnprotectedPages()).thenReturn(matchingRules); + } + + return new CsrfValidator(); + } + + private void assertPagesAreProtected(final CsrfValidator csrfValidator, final List resources) { + assertPageProtection(csrfValidator, true, resources); + } + + private void assertPagesAreNotProtected(final CsrfValidator csrfValidator, final List resources) { + assertPageProtection(csrfValidator, false, resources); + } + + private void assertPageProtection(final CsrfValidator csrfValidator, final boolean isProtected, final List resources) { + resources.forEach(resource -> { + final ProtectionResult protectionResult = csrfValidator.isProtectedPageAndMethod(resource, "not relevant"); + + testResourceNormalized(resource, protectionResult); + + if (isProtected != protectionResult.isProtected()) { + fail(String.format("The '%s' resource should %s protected!", resource, isProtected ? "be" : "not be")); + } + }); + } + + private void testResourceNormalized(final String resource, final ProtectionResult protectionResult) { + final String normalizedResource = protectionResult.getResourceIdentifier(); + final Predicate normalizationTester = startingCharacter -> (normalizedResource.charAt(0) != startingCharacter) && (normalizedResource.charAt(1) != startingCharacter); + + if (normalizationTester.test('/') && normalizationTester.test('^')) { + fail(String.format("The '%s' resource should start with a '^' character if a regular expression has matched it, '/' otherwise!", resource)); + } + } +} diff --git a/pom.xml b/pom.xml index a82b222..5b408a9 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,7 @@ 2.7 2.8.6 5.7.0 + 3.6.0 @@ -106,6 +107,19 @@ ${junit.version} test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + org.mockito + mockito-inline + ${mockito.version} + test + @@ -193,4 +207,4 @@ - \ No newline at end of file +