
/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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
 *
 * http://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 userguide.springboot.security.webservices;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.web.util.matcher.RequestMatcher;

import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;

public class BadRequestMatcher implements RequestMatcher {

    /** commons logging declaration. **/
    private static Log logger = LogFactory.getLog(BadRequestMatcher.class);
          
    private Set<String> encodedUrlBlacklist = new HashSet<String>();

    private Set<String> decodedUrlBlacklist = new HashSet<String>();
    
    private static final String ENCODED_PERCENT = "%25";

    private static final String PERCENT = "%";

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

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

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

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

    private int requestDebuggingActivated;

    public BadRequestMatcher(int requestDebuggingActivated) {
        
	this.requestDebuggingActivated = requestDebuggingActivated;
        // this is a 'defense in depth' strategy as Cloudflare or another load balancer should reject this stuff
        urlBlacklistsAddAll(FORBIDDEN_SEMICOLON);
        urlBlacklistsAddAll(FORBIDDEN_FORWARDSLASH);
        urlBlacklistsAddAll(FORBIDDEN_BACKSLASH);

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

    public boolean validate(HttpServletRequest request) {
        return matches(request);	    
    }        
    @Override
    public boolean matches(HttpServletRequest request) {
        String logPrefix = "BadRequestMatcher.matches , ";
	boolean foundElements = false;
        for (Enumeration en = request.getParameterNames(); en
                .hasMoreElements();) {

            foundElements = true;

            Object obj = en.nextElement();
            String value = request.getParameterValues((String) obj)[0];
            if (!isNormalized(value)) {
                logger.error(logPrefix + 
		    "found illegal String: " +value+ " , returning false because the request has parameters that are not 'normalized i.e. paths contain dir traversal or illegal chars'");
		return false;
                
            }
            if (!rejectedBlacklistedValues(value)) {
                logger.error(logPrefix + 
		    "found illegal String: " +value+ " , returning false because the request has rejected black list values");
		return false;
                
            }
            if (requestDebuggingActivated == 1) {
                logger.error(logPrefix + 
		    "on requestDebuggingActivated=1 found String: " +value);
                
            }
        }
        if (!foundElements) {
            logger.warn(logPrefix + "on requestDebuggingActivated=1 , no HTTP elements found!");
        }
        rejectedBlacklistedUrls(request);
        if (!isNormalized(request)) {
            logger.error(logPrefix + 
	        "inside BadRequestMatcher.matches, returning false because the request was not 'normalized i.e. paths contain dir traversal or illegal chars'");
            return false;
        }
        String requestUri = request.getRequestURI();
        if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
            logger.error(logPrefix +
                "The requestURI was rejected because it can only contain printable ASCII characters.");
            return false;

        }
        return true;
    }
    
    private 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 boolean rejectedBlacklistedUrls(HttpServletRequest request) {
        String logPrefix = "BadRequestMatcher.rejectedBlacklistedUrls , ";
        for (String forbidden : this.encodedUrlBlacklist) {
            if (encodedUrlContains(request, forbidden)) {
                logger.error(logPrefix + 
	            "returning false, The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
		return false;
            }
        }
        for (String forbidden : this.decodedUrlBlacklist) {
            if (decodedUrlContains(request, forbidden)) {
                logger.error(logPrefix + 
                    "The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
		return false;
            }
        }
        return true;
    }
    
    private boolean rejectedBlacklistedValues(String value) {
        String logPrefix = "BadRequestMatcher.matches , ";
        for (String forbidden : this.encodedUrlBlacklist) {
            if (valueContains(value, forbidden)) {
                logger.error(logPrefix + "found illegal String: " +value+ " , returning false because the request has parameters that are not 'normalized i.e. paths contain dir traversal or illegal chars'");
		return false;
            }
        }    
        return true;
    }
    
    private boolean valueContains(String value, String contains) {
        return value != null && value.contains(contains);
    }

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

    private boolean decodedUrlContains(HttpServletRequest request, String value) {
        if (valueContains(request.getServletPath(), value)) {
            return true;
        }
        if (valueContains(request.getPathInfo(), value)) {
            return true;
        }
        return false;
    }
    
    /**
     * This should be done by Spring Security StrictHttpFirewall but isn't working as expected, 
     * turns out its not as detailed as we need. 
     * Instead of sub-classing it to add logging - there is none - and features, just do the important parts here
     *  
     * 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 boolean isNormalized(HttpServletRequest request) {
        String logPrefix = "BadRequestMatcher.isNormalized , ";
        if (!isNormalized(request.getRequestURI())) {
            logger.error(logPrefix + "returning false on request.getRequestURI() : " + request.getRequestURI());
            return false;
        }
        if (!isNormalized(request.getContextPath())) {
            logger.error(logPrefix + "returning false on request.getContextPath() : " + request.getContextPath());
            return false;
        }
        if (!isNormalized(request.getServletPath())) {
            logger.error(logPrefix + "returning false on request.getServletPath() : " + request.getServletPath());
            return false;
        }
        if (!isNormalized(request.getPathInfo())) {
            logger.error(logPrefix + "returning false on request.getPathInfo() : " + request.getPathInfo());
            return false;
        }
        return true;
    }
    
    private boolean isNormalized(String path) {
        
        String logPrefix = "BadRequestMatcher.isNormalized(String path) , ";
        
        logger.warn(logPrefix + "evaluating path : " + path);
        
        if (path == null) {
            return true;
        }

        if (path.indexOf("//") > -1) {
            return false;
        }

        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;
      }

}
