SecurityUtil.java

/*
 * Copyright (c) 2002-2022, City of Paris
 * 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
 *     and the following disclaimer.
 *
 *  2. Redistributions in binary form must reproduce the above copyright notice
 *     and the following disclaimer in the documentation and/or other materials
 *     provided with the distribution.
 *
 *  3. Neither the name of 'Mairie de Paris' nor 'Lutece' 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 HOLDERS 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.
 *
 * License 1.0
 */
package fr.paris.lutece.util.http;

import java.util.Enumeration;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.util.AntPathMatcher;

import fr.paris.lutece.portal.service.util.AppPathService;
import fr.paris.lutece.portal.service.util.AppPropertiesService;
import fr.paris.lutece.portal.web.LocalVariables;
import fr.paris.lutece.util.string.StringUtil;

/**
 * Security utils
 *
 */
public final class SecurityUtil
{
    private static final String LOGGER_NAME = "lutece.security.http";
    private static final String CONSTANT_HTTP_HEADER_X_FORWARDED_FOR = "X-Forwarded-For";
    private static final String PATTERN_IP_ADDRESS = "^([0-9]{1,3}\\.){3}[0-9]{1,3}$";
    private static final String CONSTANT_COMMA = ",";
    private static final String [ ] XXE_TERMS = {
            "!DOCTYPE", "!ELEMENT", "!ENTITY"
    };
    private static final String [ ] PATH_MANIPULATION = {
            "..", "/", "\\"
    };

    public static final String PROPERTY_REDIRECT_URL_SAFE_PATTERNS = "lutece.security.redirectUrlSafePatterns";
    public static final Logger _log = LogManager.getLogger( LOGGER_NAME );

    /**
     * Private Constructor
     */
    private SecurityUtil( )
    {
    }

    /**
     * Scan request parameters to see if there no malicious code.
     *
     * @param request
     *            The HTTP request
     * @return true if all parameters don't contains any special characters
     */
    public static boolean containsCleanParameters( HttpServletRequest request )
    {
        return containsCleanParameters( request, null );
    }

    /**
     * Scan request parameters to see if there no malicious code.
     *
     * @param request
     *            The HTTP request
     * @param strXssCharacters
     *            a String wich contain a list of Xss characters to check in strValue
     * @return true if all parameters don't contains any special characters
     */
    public static boolean containsCleanParameters( HttpServletRequest request, String strXssCharacters )
    {
        String key;
        String [ ] values;
        Enumeration<String> e = request.getParameterNames( );

        while ( e.hasMoreElements( ) )
        {
            key = e.nextElement( );
            values = request.getParameterValues( key );

            int length = values.length;

            for ( int i = 0; i < length; i++ )
            {
                if ( SecurityUtil.containsXssCharacters( request, values [i], strXssCharacters ) || SecurityUtil.containsXssCharacters( request, key, strXssCharacters )  )
                {
                    _log.warn( "SECURITY WARNING : INVALID REQUEST PARAMETERS {}", ( ) -> dumpRequest( request ) );

                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Checks if a String contains characters that could be used for a cross-site scripting attack.
     *
     * @param request
     *            The HTTP request
     * @param strString
     *            a character String
     * @return true if the String contains illegal characters
     */
    public static boolean containsXssCharacters( HttpServletRequest request, String strString )
    {
        return containsXssCharacters( request, strString, null );
    }

    /**
     * Checks if a String contains characters that could be used for a cross-site scripting attack.
     *
     * @param request
     *            The HTTP request
     * @param strValue
     *            a character String
     * @param strXssCharacters
     *            a String wich contain a list of Xss characters to check in strValue
     * @return true if the String contains illegal characters
     */
    public static boolean containsXssCharacters( HttpServletRequest request, String strValue, String strXssCharacters )
    {
        boolean bContains = ( strXssCharacters == null ) ? StringUtil.containsXssCharacters( strValue )
                : StringUtil.containsXssCharacters( strValue, strXssCharacters );

        if ( bContains )
        {
            _log.warn( "SECURITY WARNING : XSS CHARACTERS DETECTED {}", ( ) -> dumpRequest( request ) );
        }

        return bContains;
    }

    /**
     * Check if the value contains terms used for XML External Entity Injection
     * 
     * @param strValue
     *            The value
     * @return true if
     */
    public static boolean containsXmlExternalEntityInjectionTerms( String strValue )
    {
        for ( String strTerm : XXE_TERMS )
        {
            if ( StringUtils.indexOfIgnoreCase( strValue, strTerm ) >= 0 )
            {
                _log.warn( "SECURITY WARNING : XXE TERMS DETECTED : {}", ( ) -> dumpRequest( LocalVariables.getRequest( ) ) );
                return true;
            }
        }
        return false;
    }

    /**
     * Check if the value contains characters used for Path Manipulation
     * 
     * @param request
     *            The Http request
     * @param strValue
     *            The value
     * @return true if
     */
    public static boolean containsPathManipulationChars( HttpServletRequest request, String strValue )
    {
        for ( String strTerm : PATH_MANIPULATION )
        {
            if ( strValue.contains( strTerm ) )
            {
                _log.warn( "SECURITY WARNING : PATH_MANIPULATION DETECTED : {}", ( ) -> dumpRequest( request ) );
                return true;
            }
        }
        return false;
    }

    /**
     * Dump all request info
     * 
     * @param request
     *            The HTTP request
     * @return A report containing all request info
     */
    public static String dumpRequest( HttpServletRequest request )
    {
        StringBuilder sbDump = new StringBuilder( "\r\n Request Dump : \r\n" );
        if ( request != null )
        {
            dumpTitle( sbDump, "Request variables" );
            dumpVariables( sbDump, request );
            dumpTitle( sbDump, "Request parameters" );
            dumpParameters( sbDump, request );
            dumpTitle( sbDump, "Request headers" );
            dumpHeaders( sbDump, request );
        }
        else
        {
            sbDump.append( "no request provided." );
        }

        return sbDump.toString( );
    }

    /**
     * Get the IP of the user from a request. If the user is behind an apache server, return the ip of the user instead of the ip of the server.
     * 
     * @param request
     *            The request
     * @return The IP of the user that made the request
     */
    public static String getRealIp( HttpServletRequest request )
    {
        String strIPAddress = request.getHeader( CONSTANT_HTTP_HEADER_X_FORWARDED_FOR );

        if ( strIPAddress != null )
        {
            while ( !strIPAddress.matches( PATTERN_IP_ADDRESS ) && strIPAddress.contains( CONSTANT_COMMA ) )
            {
                String strIpForwarded = strIPAddress.substring( 0, strIPAddress.indexOf( CONSTANT_COMMA ) );
                strIPAddress = strIPAddress.substring( strIPAddress.indexOf( CONSTANT_COMMA ) ).replaceFirst( CONSTANT_COMMA, StringUtils.EMPTY ).trim( );

                if ( ( strIpForwarded != null ) && strIpForwarded.matches( PATTERN_IP_ADDRESS ) )
                {
                    strIPAddress = strIpForwarded;
                }
            }

            if ( !strIPAddress.matches( PATTERN_IP_ADDRESS ) )
            {
                strIPAddress = request.getRemoteAddr( );
            }
        }
        else
        {
            strIPAddress = request.getRemoteAddr( );
        }

        return strIPAddress;
    }

    /**
     * Validate a forward URL to avoid open redirect with url safe patterns found in properties
     * 
     * @see SecurityUtil#isInternalRedirectUrlSafe(java.lang.String, javax.servlet.http.HttpServletRequest, java.lang.String)
     * 
     * @param strUrl
     * @param request
     * @return true if valid
     */
    public static boolean isInternalRedirectUrlSafe( String strUrl, HttpServletRequest request )
    {
        String strAntPathMatcherPatternsValues = AppPropertiesService.getProperty( SecurityUtil.PROPERTY_REDIRECT_URL_SAFE_PATTERNS );

        return isInternalRedirectUrlSafe( strUrl, request, strAntPathMatcherPatternsValues );
    }

    /**
     * Validate an internal redirect URL to avoid internal open redirect. (Use this function only if the use of internal url redirect keys is not possible. For
     * external url redirection control, use the plugin plugin-verifybackurl)
     * 
     * the url should : - not be blank (null or empty string or spaces) - not start with "http://" or "https://" or "//" OR match the base URL or any URL in the
     * pattern list
     * 
     * example with a base url "https://lutece.fr/ : - valid : myapp/jsp/site/Portal.jsp , Another.jsp , https://lutece.fr/myapp/jsp/site/Portal.jsp - invalid :
     * http://anothersite.com , https://anothersite.com , //anothersite.com , file://my.txt , ...
     * 
     * 
     * @param strUrl
     *            the Url to validate
     * @param request
     *            the current request (containing the baseUrl)
     * @param strAntPathMatcherPatterns
     *            a comma separated list of AntPathMatcher patterns, as "http://**.lutece.com,https://**.lutece.com"
     * @return true if valid
     */
    public static boolean isInternalRedirectUrlSafe( String strUrl, HttpServletRequest request, String strAntPathMatcherPatterns )
    {

        if ( StringUtils.isBlank( strUrl ) )
        {
            return true; // this is not a valid redirect Url, but it is not unsafe
        }

        // filter schemes
        boolean [ ] conditions = new boolean [ ] {
                !strUrl.startsWith( "//" ), !strUrl.startsWith( "http:" ), !strUrl.startsWith( "https:" ), !strUrl.contains( "://" ),
                !strUrl.startsWith( "javascript:" )
        };

        if ( BooleanUtils.and( conditions ) )
        {
            return true; // should be a relative path
        }

        // compare with current baseUrl
        if ( strUrl.startsWith( AppPathService.getBaseUrl( request ) ) )
        {
            return true;
        }

        // compare with allowed url patterns
        if ( !StringUtils.isBlank( strAntPathMatcherPatterns ) )
        {
            AntPathMatcher pathMatcher = new AntPathMatcher( );

            String [ ] strAntPathMatcherPatternsTab = strAntPathMatcherPatterns.split( CONSTANT_COMMA );
            for ( String pattern : strAntPathMatcherPatternsTab )
            {
                if ( pattern != null && pathMatcher.match( pattern, strUrl ) )
                {
                    return true;
                }
            }
        }

        // the Url does not match the allowed patterns
        _log.warn( "SECURITY WARNING : OPEN_REDIRECT DETECTED : {}", ( ) -> dumpRequest( request ) );

        return false;

    }

    /**
     * Identify user data saved in log files to prevent Log Forging attacks
     * 
     * @param strUserInputData
     *            User Input Data
     * @return The User Data to log
     */
    public static String logForgingProtect( String strUserInputData )
    {
        int nCharCount = strUserInputData.length( );
        int nLineCount = StringUtils.countMatches( strUserInputData, "\n" );
        String strPrefixedLines = strUserInputData.replace( "\n", "\n** " );
        return "\n** USER INPUT DATA : BEGIN (" + nLineCount + " lines and " + nCharCount + " chars) ** \n" + strPrefixedLines + "\n** USER INPUT DATA : END\n";
    }

    /**
     * Write a title into the dump stringbuffer
     * 
     * @param sbDump
     *            The dump stringbuffer
     * @param strTitle
     *            The title
     */
    private static void dumpTitle( StringBuilder sbDump, String strTitle )
    {
        sbDump.append( "** " );
        sbDump.append( strTitle );
        sbDump.append( "  **\r\n" );
    }

    /**
     * Write request variables into the dump stringbuffer
     * 
     * @param sb
     *            The dump stringbuffer
     * @param request
     *            The HTTP request
     */
    private static void dumpVariables( StringBuilder sb, HttpServletRequest request )
    {
        dumpVariable( sb, "AUTH_TYPE", request.getAuthType( ) );
        dumpVariable( sb, "REQUEST_METHOD", request.getMethod( ) );
        dumpVariable( sb, "PATH_INFO", request.getPathInfo( ) );
        dumpVariable( sb, "PATH_TRANSLATED", request.getPathTranslated( ) );
        dumpVariable( sb, "QUERY_STRING", request.getQueryString( ) );
        dumpVariable( sb, "REQUEST_URI", request.getRequestURI( ) );
        dumpVariable( sb, "SCRIPT_NAME", request.getServletPath( ) );
        dumpVariable( sb, "LOCAL_ADDR", request.getLocalAddr( ) );
        dumpVariable( sb, "SERVER_PROTOCOL", request.getProtocol( ) );
        dumpVariable( sb, "REMOTE_ADDR", request.getRemoteAddr( ) );
        dumpVariable( sb, "REMOTE_HOST", request.getRemoteHost( ) );
        dumpVariable( sb, "HTTPS", request.getScheme( ) );
        dumpVariable( sb, "SERVER_NAME", request.getServerName( ) );
        dumpVariable( sb, "SERVER_PORT", String.valueOf( request.getServerPort( ) ) );
    }

    /**
     * Write request headers infos into the dump stringbuffer
     * 
     * @param sb
     *            The dump stringbuffer
     * @param request
     *            The HTTP request
     */
    private static void dumpHeaders( StringBuilder sb, HttpServletRequest request )
    {
        Enumeration<String> values;
        String key;
        Enumeration<String> headers = request.getHeaderNames( );

        while ( headers.hasMoreElements( ) )
        {
            key = headers.nextElement( );
            values = request.getHeaders( key );

            while ( values.hasMoreElements( ) )
            {
                dumpVariable( sb, key, values.nextElement( ) );
            }
        }
    }

    /**
     * Write request parameters infos into the dump stringbuffer
     * 
     * @param sb
     *            The dump stringbuffer
     * @param request
     *            The HTTP request
     */
    private static void dumpParameters( StringBuilder sb, HttpServletRequest request )
    {
        String key;
        String [ ] values;

        Enumeration<String> e = request.getParameterNames( );

        while ( e.hasMoreElements( ) )
        {
            key = e.nextElement( );
            values = request.getParameterValues( key );

            int length = values.length;

            for ( int i = 0; i < length; i++ )
            {
                dumpVariable( sb, key, values [i] );
            }
        }
    }

    /**
     * Write name / value infos into the dump stringbuffer
     * 
     * @param sb
     *            The dump string buffer
     * @param strName
     *            The info name
     * @param strValue
     *            The info value
     */
    private static void dumpVariable( StringBuilder sb, String strName, String strValue )
    {
        sb.append( strName );
        sb.append( " : \"" );
        sb.append( strValue );
        sb.append( "\"\r\n" );
    }
}