org.springframework.security.web.firewall.StrictHttpFirewall.java Source code

Java tutorial

Introduction

Here is the source code for org.springframework.security.web.firewall.StrictHttpFirewall.java

Source

/*
 * Copyright 2012-2019 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.security.web.firewall;

import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpMethod;

/**
 * <p>
 * A strict implementation of {@link HttpFirewall} that rejects any suspicious requests
 * with a {@link RequestRejectedException}.
 * </p>
 * <p>
 * The following rules are applied to the firewall:
 * </p>
 * <ul>
 * <li>
 * Rejects HTTP methods that are not allowed. This specified to block
 * <a href="https://www.owasp.org/index.php/Test_HTTP_Methods_(OTG-CONFIG-006)">HTTP Verb tampering and XST attacks</a>.
 * See {@link #setAllowedHttpMethods(Collection)}
 * </li>
 * <li>
 * Rejects URLs that are not normalized to avoid bypassing security constraints. There is
 * no way to disable this as it is considered extremely risky to disable this constraint.
 * A few options to allow this behavior is to normalize the request prior to the firewall
 * or using {@link DefaultHttpFirewall} instead. Please keep in mind that normalizing the
 * request is fragile and why requests are rejected rather than normalized.
 * </li>
 * <li>
 * Rejects URLs that contain characters that are not printable ASCII characters. There is
 * no way to disable this as it is considered extremely risky to disable this constraint.
 * </li>
 * <li>
 * Rejects URLs that contain semicolons. See {@link #setAllowSemicolon(boolean)}
 * </li>
 * <li>
 * Rejects URLs that contain a URL encoded slash. See
 * {@link #setAllowUrlEncodedSlash(boolean)}
 * </li>
 * <li>
 * Rejects URLs that contain a backslash. See {@link #setAllowBackSlash(boolean)}
 * </li>
 * <li>
 * Rejects URLs that contain a URL encoded percent. See
 * {@link #setAllowUrlEncodedPercent(boolean)}
 * </li>
 * <li>
 * Rejects hosts that are not allowed. See
 * {@link #setAllowedHostnames(Predicate)}
 * </li>
 * </ul>
 *
 * @see DefaultHttpFirewall
 * @author Rob Winch
 * @author Edd Melndez
 * @since 4.2.4
 */
public class StrictHttpFirewall implements HttpFirewall {
    /**
     * Used to specify to {@link #setAllowedHttpMethods(Collection)} that any HTTP method should be allowed.
     */
    private static final Set<String> ALLOW_ANY_HTTP_METHOD = Collections.unmodifiableSet(Collections.emptySet());

    private static final String ENCODED_PERCENT = "%25";

    private static final String PERCENT = "%";

    private static final List<String> FORBIDDEN_ENCODED_PERIOD = Collections
            .unmodifiableList(Arrays.asList("%2e", "%2E"));

    private static final List<String> FORBIDDEN_SEMICOLON = Collections
            .unmodifiableList(Arrays.asList(";", "%3b", "%3B"));

    private static final List<String> FORBIDDEN_FORWARDSLASH = Collections
            .unmodifiableList(Arrays.asList("%2f", "%2F"));

    private static final List<String> FORBIDDEN_DOUBLE_FORWARDSLASH = Collections
            .unmodifiableList(Arrays.asList("//", "%2f%2f", "%2f%2F", "%2F%2f", "%2F%2F"));

    private static final List<String> FORBIDDEN_BACKSLASH = Collections
            .unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));

    private Set<String> encodedUrlBlacklist = new HashSet<>();

    private Set<String> decodedUrlBlacklist = new HashSet<>();

    private Set<String> allowedHttpMethods = createDefaultAllowedHttpMethods();

    private Predicate<String> allowedHostnames = hostname -> true;

    public StrictHttpFirewall() {
        urlBlacklistsAddAll(FORBIDDEN_SEMICOLON);
        urlBlacklistsAddAll(FORBIDDEN_FORWARDSLASH);
        urlBlacklistsAddAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
        urlBlacklistsAddAll(FORBIDDEN_BACKSLASH);

        this.encodedUrlBlacklist.add(ENCODED_PERCENT);
        this.encodedUrlBlacklist.addAll(FORBIDDEN_ENCODED_PERIOD);
        this.decodedUrlBlacklist.add(PERCENT);
    }

    /**
     * Sets if any HTTP method is allowed. If this set to true, then no validation on the HTTP method will be performed.
     * This can open the application up to <a href="https://www.owasp.org/index.php/Test_HTTP_Methods_(OTG-CONFIG-006)">
     * HTTP Verb tampering and XST attacks</a>
     * @param unsafeAllowAnyHttpMethod if true, disables HTTP method validation, else resets back to the defaults. Default is false.
     * @see #setAllowedHttpMethods(Collection)
     * @since 5.1
     */
    public void setUnsafeAllowAnyHttpMethod(boolean unsafeAllowAnyHttpMethod) {
        this.allowedHttpMethods = unsafeAllowAnyHttpMethod ? ALLOW_ANY_HTTP_METHOD
                : createDefaultAllowedHttpMethods();
    }

    /**
     * <p>
     * Determines which HTTP methods should be allowed. The default is to allow "DELETE", "GET", "HEAD", "OPTIONS",
     * "PATCH", "POST", and "PUT".
     * </p>
     *
     * @param allowedHttpMethods the case-sensitive collection of HTTP methods that are allowed.
     * @see #setUnsafeAllowAnyHttpMethod(boolean)
     * @since 5.1
     */
    public void setAllowedHttpMethods(Collection<String> allowedHttpMethods) {
        if (allowedHttpMethods == null) {
            throw new IllegalArgumentException("allowedHttpMethods cannot be null");
        }
        if (allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
            this.allowedHttpMethods = ALLOW_ANY_HTTP_METHOD;
        } else {
            this.allowedHttpMethods = new HashSet<>(allowedHttpMethods);
        }
    }

    /**
     * <p>
     * Determines if semicolon is allowed in the URL (i.e. matrix variables). The default
     * is to disable this behavior because it is a common way of attempting to perform
     * <a href="https://www.owasp.org/index.php/Reflected_File_Download">Reflected File Download Attacks</a>.
     * It is also the source of many exploits which bypass URL based security.
     * </p>
     * <p>For example, the following CVEs are a subset of the issues related
     * to ambiguities in the Servlet Specification on how to treat semicolons that
     * led to CVEs:
     * </p>
     * <ul>
     *     <li><a href="https://pivotal.io/security/cve-2016-5007">cve-2016-5007</a></li>
     *     <li><a href="https://pivotal.io/security/cve-2016-9879">cve-2016-9879</a></li>
     *     <li><a href="https://pivotal.io/security/cve-2018-1199">cve-2018-1199</a></li>
     * </ul>
     *
     * <p>
     * If you are wanting to allow semicolons, please reconsider as it is a very common
     * source of security bypasses. A few common reasons users want semicolons and
     * alternatives are listed below:
     * </p>
     * <ul>
     * <li>Including the JSESSIONID in the path - You should not include session id (or
     * any sensitive information) in a URL as it can lead to leaking. Instead use Cookies.
     * </li>
     * <li>Matrix Variables - Users wanting to leverage Matrix Variables should consider
     * using HTTP parameters instead.
     * </li>
     * </ul>
     *
     * @param allowSemicolon should semicolons be allowed in the URL. Default is false
     */
    public void setAllowSemicolon(boolean allowSemicolon) {
        if (allowSemicolon) {
            urlBlacklistsRemoveAll(FORBIDDEN_SEMICOLON);
        } else {
            urlBlacklistsAddAll(FORBIDDEN_SEMICOLON);
        }
    }

    /**
     * <p>
     * Determines if a slash "/" that is URL encoded "%2F" should be allowed in the path
     * or not. The default is to not allow this behavior because it is a common way to
     * bypass URL based security.
     * </p>
     * <p>
     * For example, due to ambiguities in the servlet specification, the value is not
     * parsed consistently which results in different values in {@code HttpServletRequest}
     * path related values which allow bypassing certain security constraints.
     * </p>
     *
     * @param allowUrlEncodedSlash should a slash "/" that is URL encoded "%2F" be allowed
     * in the path or not. Default is false.
     */
    public void setAllowUrlEncodedSlash(boolean allowUrlEncodedSlash) {
        if (allowUrlEncodedSlash) {
            urlBlacklistsRemoveAll(FORBIDDEN_FORWARDSLASH);
        } else {
            urlBlacklistsAddAll(FORBIDDEN_FORWARDSLASH);
        }
    }

    /**
     * <p>
     * Determines if double slash "//" that is URL encoded "%2F%2F" should be allowed in the path or
     * not. The default is to not allow.
     * </p>
     *
     * @param allowUrlEncodedDoubleSlash should a slash "//" that is URL encoded "%2F%2F" be allowed
     *        in the path or not. Default is false.
     */
    public void setAllowUrlEncodedDoubleSlash(boolean allowUrlEncodedDoubleSlash) {
        if (allowUrlEncodedDoubleSlash) {
            urlBlacklistsRemoveAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
        } else {
            urlBlacklistsAddAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
        }
    }

    /**
     * <p>
     * Determines if a period "." that is URL encoded "%2E" should be allowed in the path
     * or not. The default is to not allow this behavior because it is a frequent source
     * of security exploits.
     * </p>
     * <p>
     * For example, due to ambiguities in the servlet specification a URL encoded period
     * might lead to bypassing security constraints through a directory traversal attack.
     * This is because the path is not parsed consistently which results  in different
     * values in {@code HttpServletRequest} path related values which allow bypassing
     * certain security constraints.
     * </p>
     *
     * @param allowUrlEncodedPeriod should a period "." that is URL encoded "%2E" be
     * allowed in the path or not. Default is false.
     */
    public void setAllowUrlEncodedPeriod(boolean allowUrlEncodedPeriod) {
        if (allowUrlEncodedPeriod) {
            this.encodedUrlBlacklist.removeAll(FORBIDDEN_ENCODED_PERIOD);
        } else {
            this.encodedUrlBlacklist.addAll(FORBIDDEN_ENCODED_PERIOD);
        }
    }

    /**
     * <p>
     * Determines if a backslash "\" or a URL encoded backslash "%5C" should be allowed in
     * the path or not. The default is not to allow this behavior because it is a frequent
     * source of security exploits.
     * </p>
     * <p>
     * For example, due to ambiguities in the servlet specification a URL encoded period
     * might lead to bypassing security constraints through a directory traversal attack.
     * This is because the path is not parsed consistently which results  in different
     * values in {@code HttpServletRequest} path related values which allow bypassing
     * certain security constraints.
     * </p>
     *
     * @param allowBackSlash a backslash "\" or a URL encoded backslash "%5C" be allowed
     * in the path or not. Default is false
     */
    public void setAllowBackSlash(boolean allowBackSlash) {
        if (allowBackSlash) {
            urlBlacklistsRemoveAll(FORBIDDEN_BACKSLASH);
        } else {
            urlBlacklistsAddAll(FORBIDDEN_BACKSLASH);
        }
    }

    /**
     * <p>
     * Determines if a percent "%" that is URL encoded "%25" should be allowed in the path
     * or not. The default is not to allow this behavior because it is a frequent source
     * of security exploits.
     * </p>
     * <p>
     * For example, this can lead to exploits that involve double URL encoding that lead
     * to bypassing security constraints.
     * </p>
     *
     * @param allowUrlEncodedPercent if a percent "%" that is URL encoded "%25" should be
     * allowed in the path or not. Default is false
     */
    public void setAllowUrlEncodedPercent(boolean allowUrlEncodedPercent) {
        if (allowUrlEncodedPercent) {
            this.encodedUrlBlacklist.remove(ENCODED_PERCENT);
            this.decodedUrlBlacklist.remove(PERCENT);
        } else {
            this.encodedUrlBlacklist.add(ENCODED_PERCENT);
            this.decodedUrlBlacklist.add(PERCENT);
        }
    }

    /**
     * <p>
     * Determines which hostnames should be allowed. The default is to allow any hostname.
     * </p>
     *
     * @param allowedHostnames the predicate for testing hostnames
     * @since 5.2
     */
    public void setAllowedHostnames(Predicate<String> allowedHostnames) {
        if (allowedHostnames == null) {
            throw new IllegalArgumentException("allowedHostnames cannot be null");
        }
        this.allowedHostnames = allowedHostnames;
    }

    private void urlBlacklistsAddAll(Collection<String> values) {
        this.encodedUrlBlacklist.addAll(values);
        this.decodedUrlBlacklist.addAll(values);
    }

    private void urlBlacklistsRemoveAll(Collection<String> values) {
        this.encodedUrlBlacklist.removeAll(values);
        this.decodedUrlBlacklist.removeAll(values);
    }

    @Override
    public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
        rejectForbiddenHttpMethod(request);
        rejectedBlacklistedUrls(request);
        rejectedUntrustedHosts(request);

        if (!isNormalized(request)) {
            throw new RequestRejectedException("The request was rejected because the URL was not normalized.");
        }

        String requestUri = request.getRequestURI();
        if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
            throw new RequestRejectedException(
                    "The requestURI was rejected because it can only contain printable ASCII characters.");
        }
        return new FirewalledRequest(request) {
            @Override
            public void reset() {
            }
        };
    }

    private void rejectForbiddenHttpMethod(HttpServletRequest request) {
        if (this.allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
            return;
        }
        if (!this.allowedHttpMethods.contains(request.getMethod())) {
            throw new RequestRejectedException("The request was rejected because the HTTP method \""
                    + request.getMethod() + "\" was not included within the whitelist " + this.allowedHttpMethods);
        }
    }

    private void rejectedBlacklistedUrls(HttpServletRequest request) {
        for (String forbidden : this.encodedUrlBlacklist) {
            if (encodedUrlContains(request, forbidden)) {
                throw new RequestRejectedException(
                        "The request was rejected because the URL contained a potentially malicious String \""
                                + forbidden + "\"");
            }
        }
        for (String forbidden : this.decodedUrlBlacklist) {
            if (decodedUrlContains(request, forbidden)) {
                throw new RequestRejectedException(
                        "The request was rejected because the URL contained a potentially malicious String \""
                                + forbidden + "\"");
            }
        }
    }

    private void rejectedUntrustedHosts(HttpServletRequest request) {
        String serverName = request.getServerName();
        if (serverName != null && !this.allowedHostnames.test(serverName)) {
            throw new RequestRejectedException(
                    "The request was rejected because the domain " + serverName + " is untrusted.");
        }
    }

    @Override
    public HttpServletResponse getFirewalledResponse(HttpServletResponse response) {
        return new FirewalledResponse(response);
    }

    private static Set<String> createDefaultAllowedHttpMethods() {
        Set<String> result = new HashSet<>();
        result.add(HttpMethod.DELETE.name());
        result.add(HttpMethod.GET.name());
        result.add(HttpMethod.HEAD.name());
        result.add(HttpMethod.OPTIONS.name());
        result.add(HttpMethod.PATCH.name());
        result.add(HttpMethod.POST.name());
        result.add(HttpMethod.PUT.name());
        return result;
    }

    private static boolean isNormalized(HttpServletRequest request) {
        if (!isNormalized(request.getRequestURI())) {
            return false;
        }
        if (!isNormalized(request.getContextPath())) {
            return false;
        }
        if (!isNormalized(request.getServletPath())) {
            return false;
        }
        if (!isNormalized(request.getPathInfo())) {
            return false;
        }
        return true;
    }

    private static boolean encodedUrlContains(HttpServletRequest request, String value) {
        if (valueContains(request.getContextPath(), value)) {
            return true;
        }
        return valueContains(request.getRequestURI(), value);
    }

    private static boolean decodedUrlContains(HttpServletRequest request, String value) {
        if (valueContains(request.getServletPath(), value)) {
            return true;
        }
        if (valueContains(request.getPathInfo(), value)) {
            return true;
        }
        return false;
    }

    private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
        int length = uri.length();
        for (int i = 0; i < length; i++) {
            char c = uri.charAt(i);
            if (c < '\u0020' || c > '\u007e') {
                return false;
            }
        }

        return true;
    }

    private static boolean valueContains(String value, String contains) {
        return value != null && value.contains(contains);
    }

    /**
     * Checks whether a path is normalized (doesn't contain path traversal
     * sequences like "./", "/../" or "/.")
     *
     * @param path
     *            the path to test
     * @return true if the path doesn't contain any path-traversal character
     *         sequences.
     */
    private static boolean isNormalized(String path) {
        if (path == null) {
            return true;
        }

        for (int j = path.length(); j > 0;) {
            int i = path.lastIndexOf('/', j - 1);
            int gap = j - i;

            if (gap == 2 && path.charAt(i + 1) == '.') {
                // ".", "/./" or "/."
                return false;
            } else if (gap == 3 && path.charAt(i + 1) == '.' && path.charAt(i + 2) == '.') {
                return false;
            }

            j = i;
        }

        return true;
    }

    /**
     * Provides the existing encoded url blacklist which can add/remove entries from
     *
     * @return the existing encoded url blacklist, never null
     */
    public Set<String> getEncodedUrlBlacklist() {
        return encodedUrlBlacklist;
    }

    /**
     * Provides the existing decoded url blacklist which can add/remove entries from
     *
     * @return the existing decoded url blacklist, never null
     */
    public Set<String> getDecodedUrlBlacklist() {
        return decodedUrlBlacklist;
    }
}