Package hirondelle.web4j.security

Source Code of hirondelle.web4j.security.ApplicationFirewallImpl

package hirondelle.web4j.security;

import java.util.*;
import java.util.logging.Logger;
import javax.servlet.ServletConfig;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import hirondelle.web4j.BuildImpl;
import hirondelle.web4j.action.Action;
import hirondelle.web4j.model.BadRequestException;
import hirondelle.web4j.readconfig.ConfigReader;
import hirondelle.web4j.readconfig.InitParam;
import hirondelle.web4j.request.RequestParameter;
import hirondelle.web4j.request.RequestParser;
import hirondelle.web4j.util.Util;
import hirondelle.web4j.action.Operation;
import hirondelle.web4j.model.Id;
import static hirondelle.web4j.util.Consts.FAILS;

/**
Default implementation of {@link ApplicationFirewall}.

<P>Upon startup, this class will inspect all {@link Action}s in the application.
All <tt>public static final</tt> {@link hirondelle.web4j.request.RequestParameter} fields accessible
to each {@link Action} will be collected, and treated here as the set of acceptable
{@link RequestParameter}s for each {@link Action} class. Thus, when this class is used to implement
{@link ApplicationFirewall}<span class="highlight">each {@link Action} must declare all expected request
parameters as a <tt>public static final</tt> {@link RequestParameter} field, in order to pass hard validation.</span>

<h3>File Upload Forms</h3>
If a POSTed request includes one or more file upload controls, then the underlying HTTP request has
a completely different structure from a regular request having no file upload controls.
Unfortunately, the Servlet API has very poor support for forms that include a file upload control: only the raw underlying request is available, <em>in an unparsed form</em>.
For such forms, POSTed data is not available in the usual way, and by default <tt>request.getParameter(String)</tt> will return <tt>null</tt>
<em>not only for the file upload control, but for all controls in the form</em>.
<P>An elegant way around this problem involves <em>wrapping</em> the request,
using {@link javax.servlet.http.HttpServletRequestWrapper}, such that POSTed data is parsed and made
available through the usual <tt>request</tt> methods.
If such a wrapper is used, then file upload forms can be handled in much the same way as any other form.
 
<P>To indicate to this class if such a wrapper is being used for file upload requests, use the <tt>FullyValidateFileUploads</tt> setting
in <tt>web.xml</tt>.

<P>Settings in <tt>web.xml</tt> affecting this class :
<ul>
<li><tt>MaxHttpRequestSize</tt>
<li><tt>MaxFileUploadRequestSize</tt>
<li><tt>MaxRequestParamValueSize</tt> (used by {@link hirondelle.web4j.request.RequestParameter})
<li><tt>SpamDetectionInFirewall</tt>
<li><tt>FullyValidateFileUploads</tt>
</ul>
<P>The above settings control the validations performed by this class :
<table   border="1" cellpadding="3" cellspacing="0">
  <tr>
   <th>Check</th>
   <th>Regular</th>
   <th>File Upload (Wrapped)</th>
   <th>File Upload</th>
  </tr>
  <tr>
   <td>Overall request size &lt;= <tt>MaxHttpRequestSize</tt> </td>
   <td>Y</td>
   <td>N</td>
   <td>N</td>
  </tr>
  <tr>
   <td>Overall request size &lt;= <tt>MaxFileUploadRequestSize</tt> </td>
   <td>N</td>
   <td>Y</td>
   <td>Y</td>
  </tr>
  <tr>
   <td>Every param <em>name</em> is among the {@link hirondelle.web4j.request.RequestParameter}s  for that {@link Action}</td>
   <td>Y</td>
   <td>Y&#042;</td>
   <td>N</td>
  </tr>
  <tr>
   <td>Every param <em>value</em> satifies {@link hirondelle.web4j.request.RequestParameter#isValidParamValue(String)}</td>
   <td>Y</td>
   <td>Y&#042;&#042;</td>
   <td>N</td>
  </tr>
  <tr>
   <td>If created with {@link hirondelle.web4j.request.RequestParameter#withLengthCheck(String)}, then param value size &lt;= <tt>MaxRequestParamValueSize</tt></td>
   <td>Y</td>
   <td>Y&#042;&#042;</td>
   <td>N</td>
  </tr>
  <tr>
   <td>If <tt>SpamDetectionInFirewall</tt> is on, then each param value is checked using the configured {@link hirondelle.web4j.security.SpamDetector}</td>
   <td>Y</td>
   <td>Y&#042;&#042;</td>
   <td>N</td>
  </tr>
  <tr>
   <td>If a request param named <tt>Operation</tt> exists and it returns <tt>true</tt> for {@link Operation#hasSideEffects()}, then the underlying request must be a <tt>POST</tt></td>
   <td>Y</td>
   <td>Y</td>
   <td>N</td>
  </tr>
  <tr>
   <td><a href='#CSRF'>CSRF Defenses</a></td>
   <td>Y</td>
   <td>Y</td>
   <td>N</td>
  </tr>
</table>
&#042; For file upload controls, the param name is checked only if the return value of <tt>getParameterNames()</tt> (for the wrapper) includes it.
<br>&#042;&#042;Except for file upload controls. For file upload <em>controls</em>, no checks on the param <em>value</em> are made by this class.<br>
<a name='CSRF'></a>
<h3>Defending Against CSRF Attacks</h3>
If the usual WEB4J defenses against CSRF attacks are active (see package-level comments),
then <i>for every <tt>POST</tt> request executed within a session</i> the following will also be performed as a defense against CSRF attacks :
<ul>
<li>validate that a request parameter named
{@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY} is present. (This
request parameter is deemed to be a special 'internal' parameter, and does not need to be explicitly declared in
your <tt>Action</tt> like other request parameters.)
<li>validate that its value matches items stored in session scope. First check versus an item stored 
under the key {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY}; if that check fails, then
check versus an item stored under the key
{@link hirondelle.web4j.security.CsrfFilter#PREVIOUS_FORM_SOURCE_ID_KEY}
</ul>

See {@link hirondelle.web4j.security.CsrfFilter} for more information.
*/
public class ApplicationFirewallImpl implements ApplicationFirewall {
 
  /**
   Called by {@link hirondelle.web4j.request.RequestParser} to initialize this class upon startup.
  
   <P>Inspects all {@link Action} classes to find the {@link RequestParameter}s expected by each one.
  
   <P>Extracts these settings from <tt>web.xml</tt>  :
  
   <table  border="1" cellpadding="3" cellspacing="0">
    <tr><th>Name</th><th>Default Value</th></tr>
    <tr><td>MaxHttpRequestSize</td><td>51200</td></tr>
    <tr><td>MaxFileUploadRequestSize</td><td>51200</td></tr>
    <tr><td>SpamDetectionInFirewall</td><td>OFF</td></tr>
    <tr><td>FullyValidateFileUploads</td><td>OFF</td></tr>
   </table> 
  */
  public static void init(ServletConfig aConfig){
    fMaxRequestSize = getMaxSize(fMAX_REQUEST_SIZE, aConfig);
    fMaxFileUploadRequestSize = getMaxSize(fMAX_FILE_UPLOAD_REQUEST_SIZE, aConfig);
    fFullyValidateFileUploads = getBooleanSetting(fFULLY_VALIDATE_FILE_UPLOADS, aConfig);
    fIsSpamDetectionOn = getBooleanSetting(fIS_SPAM_DETECTION_ON, aConfig);
    mapActionsToExpectedParams();
  }
 
  /**
   Perform checks on the incoming request.
  
   <P>See class description for more information.
  
   <P>Subclasses may extend this implementation, following the form :
   <PRE>
  public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
    super(aAction, aRequestParser);
    //place additional validations here
    //for example, one might check that a Content-Length header is present,
    //or that all header values are within some size range
  }
   </PRE>
  */
  public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
    if( aRequestParser.isFileUploadRequest() ){
      fLogger.fine("Validating a file upload request.");
    }
    checkForExtremeSize(aRequestParser);
    if ( aRequestParser.isFileUploadRequest() && ! fFullyValidateFileUploads )  {
      fLogger.fine("Unable to parse request in the usual way: file upload request is not wrapped. Cannot read parameter names and values. See FullyValidateFileUploads setting in web.xml.");
    }
    else {
      checkParamNamesAndValues(aAction, aRequestParser);
      checkSideEffectOperations(aAction, aRequestParser);
      defendAgainstCSRFAttacks(aRequestParser);
    }
  }
 
  // PRIVATE //
 
  /**
   Maps {@link Action} classes to a List of expected {@link hirondelle.web4j.request.RequestParameter} objects.
   If the incoming request contains a request parameter whose name or value is not consistent with this
   list, then a {@link hirondelle.web4j.model.BadRequestException} is thrown.
  
   <P>Key - class object
   <br>Value - Set of RequestParameter objects; may be empty, but not null.
  
   <P>This is a mutable object field, but is not modified after startup, so this class is thread-safe.
  */
  private static Map<Class<Action>, Set<RequestParameter>> fExpectedParams = new LinkedHashMap<Class<Action>, Set<RequestParameter>>();
 
  /** Max size of a valid HTTP request, in bytes. Pulled in from web.xml.  */
  private static int fMaxRequestSize;
  private static final InitParam fMAX_REQUEST_SIZE = new InitParam("MaxHttpRequestSize", "51200");

  /** Max size in bytes of an HTTP request containing a file upload. Pulled in from web.xml.  */
  private static int fMaxFileUploadRequestSize;
  private static final InitParam fMAX_FILE_UPLOAD_REQUEST_SIZE = new InitParam("MaxFileUploadRequestSize", "51200");

  /** Indicate if file upload request can be validated in detail. Pulled in from web.xml.  */
  private static boolean fFullyValidateFileUploads;
  private static final InitParam fFULLY_VALIDATE_FILE_UPLOADS = new InitParam("FullyValidateFileUploads", "OFF");
 
  /** Toggle for attempting detection of spam. Pulled in from web.xml. */
  private static boolean fIsSpamDetectionOn;
  private static final InitParam fIS_SPAM_DETECTION_ON = new InitParam("SpamDetectionInFirewall", "OFF");
 
  private static final String CURRENT_TOKEN_CSRF = CsrfFilter.FORM_SOURCE_ID_KEY;
  private static final String PREVIOUS_TOKEN_CSRF = CsrfFilter.PREVIOUS_FORM_SOURCE_ID_KEY;

  /**
   Special, 'internal' request parameter, used by the framework to defend against CSRF attacks.
  */
  private static final RequestParameter fCSRF_REQ_PARAM = RequestParameter.withLengthCheck(CsrfFilter.FORM_SOURCE_ID_KEY);
 
  private static final Logger fLogger = Util.getLogger(ApplicationFirewallImpl.class);
 
  private static int getMaxSize(InitParam aInitParam, ServletConfig aConfig){
    int result = Integer.parseInt(
      aInitParam.fetch(aConfig).getValue()
    );
    if ( result < 1000 ) {
      throw new IllegalArgumentException(
        "Configured value of " + result + " in web.xml for " +
        aInitParam.getName() +
        " is too low. Please see web.xml for more information."
      );
    }
    return result;
  }
 
  private static boolean getBooleanSetting(InitParam aInitParam, ServletConfig aConfig){
    boolean result = false;
    String value = aInitParam.fetch(aConfig).getValue().trim();
    if( "ON".equalsIgnoreCase(value) ) {
      result = true;   
    }
    else if ("OFF".equalsIgnoreCase(value)){
      //default
    }
    else {
      throw new IllegalArgumentException(
        "Configured value of " + Util.quote(value) + " in web.xml for " +
        aInitParam.getName() +
        " is not among the expected values. Please see web.xml for more information."
      );
    }
    return result;
  }
 
 
  private static void mapActionsToExpectedParams(){
    fExpectedParams = ConfigReader.fetchPublicStaticFinalFields(Action.class, RequestParameter.class);
    fLogger.config("Expected Request Parameters per Web Action." + Util.logOnePerLine(fExpectedParams));
  }
 
  /**
   Some denial-of-service attacks place large amounts of data in the request
   params, in an attempt to overload the server. This method will check for
   such requests. This check must be performed first, before any further
   processing is attempted.
  */
  private void checkForExtremeSize(RequestParser aRequest) throws BadRequestException {
    fLogger.fine("Checking for extreme size.");
    if ( isRequestExcessivelyLarge(aRequest) ) {
      throw new BadRequestException(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
    }
  }

  private boolean isRequestExcessivelyLarge(RequestParser aRequestParser){
    boolean result = false;
    if ( aRequestParser.isFileUploadRequest() ) {
      result = aRequestParser.getRequest().getContentLength() > fMaxFileUploadRequestSize;
    }
    else {
      result = aRequestParser.getRequest().getContentLength() > fMaxRequestSize;
    }
    return result;
  }
  
  void checkParamNamesAndValues(Action aAction, RequestParser aRequestParser) throws BadRequestException {
    if ( fExpectedParams.containsKey(aAction.getClass()) ){
      Set<RequestParameter> expectedParams = fExpectedParams.get(aAction.getClass());
      //this method may return file upload controls - depends on interpretation, whether to include file upload controls in this method
      Enumeration paramNames = aRequestParser.getRequest().getParameterNames();
      while ( paramNames.hasMoreElements() ){
        String incomingParamName = (String)paramNames.nextElement();
        fLogger.fine("Checking parameter named " + Util.quote(incomingParamName));
        RequestParameter knownParam = matchToKnownParam(incomingParamName, expectedParams);
        if( knownParam == null ){
          fLogger.severe("*** Unknown Parameter *** : " + Util.quote(incomingParamName) + ". Please add public static final RequestParameter field for this item to your Action.");
          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
        }
        if ( knownParam.isFileUploadParameter() ) {
          fLogger.fine("File Upload parameter - value not validatable here: " + knownParam.getName());
          continue; //prevents checks on values for file upload controls
        }
        Collection<SafeText> paramValues = aRequestParser.toSafeTexts(knownParam);
        if( ! isInternalParam( knownParam) ) {
          checkParamValues(knownParam, paramValues);
        }
      }
    }
    else {
      String message = "Action " + aAction.getClass() + " not known to ApplicationFirewallImpl.";
      fLogger.severe(message);
      //this is NOT a BadRequestEx, since not outside the control of this framework.
      throw new RuntimeException(message);
    }
  }
 
  /** If no match is found, return <tt>null</tt>.   Matches to both regular and 'internal' request params. */
  private RequestParameter matchToKnownParam(String aIncomingParamName, Collection<RequestParameter> aExpectedParams){
    RequestParameter result = null;
    for (RequestParameter reqParam: aExpectedParams){
      if ( reqParam.getName().equals(aIncomingParamName) ){
        result = reqParam;
        break;
      }
    }
    if( result == null && fCSRF_REQ_PARAM.getName().equals(aIncomingParamName) ){
      result = fCSRF_REQ_PARAM;
    }
    return result;
  }
 
  private void checkParamValues(RequestParameter aKnownReqParam, Collection<SafeText> aParamValues) throws BadRequestException {
    for(SafeText paramValue: aParamValues){
      if ( Util.textHasContent(paramValue) ) {
        if ( ! aKnownReqParam.isValidParamValue(paramValue.getRawString()) ) {
          fLogger.severe("Request parameter named " + aKnownReqParam.getName() + " has an invalid value. Its size is: " + paramValue.getRawString().length());
          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
        }
        if( fIsSpamDetectionOn ){
          SpamDetector spamDetector = BuildImpl.forSpamDetector();
          if( spamDetector.isSpam(paramValue.getRawString()) ){
            fLogger.fine("SPAM detected.");
            throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
          }
        }
      }
    }
  }
 
  private void checkSideEffectOperations(Action aAction, RequestParser aRequestParser) throws BadRequestException {
    fLogger.fine("Checking for side-effect operations.");
    Set<RequestParameter> expectedParams = fExpectedParams.get(aAction.getClass());
    for (RequestParameter reqParam : expectedParams){
      if ( "Operation".equals(reqParam.getName()) ){
        String rawValue = aRequestParser.getRawParamValue(reqParam);
        if (Util.textHasContent(rawValue)){
          Operation operation = Operation.valueOf(rawValue);
          if ( isAttemptingSideEffectOperationWithoutPOST(operation, aRequestParser) ){
            fLogger.severe("Security problem. Attempted operation having side effects outside of a POST. Please use a <FORM> with method='POST'.");
            throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
          }
        }
      }
    }
  }
 
  private boolean isAttemptingSideEffectOperationWithoutPOST(Operation aOperation, RequestParser aRequestParser){
    return aOperation.hasSideEffects() && !aRequestParser.getRequest().getMethod().equals("POST");
  }

  /**
   An internal request param is not declared explicitly by the application programmer. Rather, it is defined and
   used only by the framework.
  */
  private boolean  isInternalParam(RequestParameter aRequestParam) {
    return aRequestParam.getName().equals(fCSRF_REQ_PARAM.getName());
  }

  private void defendAgainstCSRFAttacks(RequestParser aRequestParser) throws BadRequestException {
    if( requestNeedsDefendingAgainstCSRFAttacks(aRequestParser) ) {
      Id postedTokenValue = aRequestParser.toId(fCSRF_REQ_PARAM);
      if ( FAILS == toIncludeCsrfTokenWithForm(postedTokenValue) ){
        fLogger.severe("CSRF token not included in POSTed request. Rejecting this request, since it is likely an attack.");
        throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
      }
     
      if( FAILS == matchCurrentCSRFToken(aRequestParser, postedTokenValue) ) {
        if( FAILS == matchPreviousCSRFToken(aRequestParser, postedTokenValue) ) {
          fLogger.severe("CSRF token does not match the expected value. Rejecting this request, since it is likely an attack.");
          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);       
        }
      }
      fLogger.fine("Success: no CSRF problem detected.");
    }
  }
 
  private boolean requestNeedsDefendingAgainstCSRFAttacks(RequestParser aRequestParser){
    boolean isPOST =  aRequestParser.getRequest().getMethod().equalsIgnoreCase("POST");
    boolean sessionPresent = isSessionPresent(aRequestParser);
    boolean csrfFilterIsTurnedOn = false;
    if( sessionPresent ) {
      Id csrfTokenInSession = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser);
      csrfFilterIsTurnedOn = (csrfTokenInSession != null);
    }
   
    if( isPOST &&  sessionPresent && ! csrfFilterIsTurnedOn )  {
      fLogger.warning("POST operation, but no CSRF form token present in existing session. This application does not have WEB4J defenses against CSRF attacks configured in the recommended way.")
    }
   
    boolean result =  isPOST && sessionPresent && csrfFilterIsTurnedOn;
    fLogger.fine("Session exists, and the CsrfFilter is turned on : " + csrfFilterIsTurnedOn);
    fLogger.fine("Does the firewall need to check this request for CSRF attacks? : " + result);
    return result;
  }
 
  private boolean toIncludeCsrfTokenWithForm(Id aCsrfToken){
    return aCsrfToken != null;
  }
 
  private boolean matchCurrentCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue) {
    Id currentToken = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser);
    return aPostedTokenValue.equals(currentToken);
  }
 
  private boolean matchPreviousCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue){
    //in the case of an anonymous session, with no login, this item will be null
    Id previousToken = getCsrfTokenInSession(PREVIOUS_TOKEN_CSRF, aRequestParser);
    return aPostedTokenValue.equals(previousToken);
  }

  private boolean isSessionPresent(RequestParser aRequestParser){
    boolean DO_NOT_CREATE = false;
    HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE);
    return session != null;
  }
 
  /** Only called when session is present. No risk of null pointer exception. */
  private Id getCsrfTokenInSession(String aKey, RequestParser aRequestParser){
    boolean DO_NOT_CREATE = false;
    HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE);
    return (Id)session.getAttribute(aKey);
  }
}
TOP

Related Classes of hirondelle.web4j.security.ApplicationFirewallImpl

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.