classes/hirondelle/web4j/security/ApplicationFirewallImpl.java
changeset 0 3060119b1292
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classes/hirondelle/web4j/security/ApplicationFirewallImpl.java	Wed Dec 04 17:00:31 2013 +0100
@@ -0,0 +1,379 @@
+package hirondelle.web4j.security;
+
+import static hirondelle.web4j.util.Consts.FAILS;
+import hirondelle.web4j.BuildImpl;
+import hirondelle.web4j.action.Action;
+import hirondelle.web4j.action.Operation;
+import hirondelle.web4j.model.BadRequestException;
+import hirondelle.web4j.model.Id;
+import hirondelle.web4j.readconfig.Config;
+import hirondelle.web4j.readconfig.ConfigReader;
+import hirondelle.web4j.request.RequestParameter;
+import hirondelle.web4j.request.RequestParser;
+import hirondelle.web4j.util.Util;
+
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+
+import javax.servlet.ServletConfig;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+/**
+ 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 {
+
+  /** Map actions to expected params. */
+  public static void init(){
+    fExpectedParams = ConfigReader.fetchPublicStaticFinalFields(Action.class, RequestParameter.class);
+    fLogger.config("Expected Request Parameters per Web Action." + Util.logOnePerLine(fExpectedParams));
+  }
+
+  /**
+   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() && ! fConfig.getFullyValidateFileUploads() )  {
+      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 
+
+  private Config fConfig = new Config();
+  
+  /**
+   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>>();
+
+  /** 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 String CURRENT_TOKEN_CSRF = CsrfFilter.FORM_SOURCE_ID_KEY;
+  private static final String PREVIOUS_TOKEN_CSRF = CsrfFilter.PREVIOUS_FORM_SOURCE_ID_KEY;
+  private static final Logger fLogger = Util.getLogger(ApplicationFirewallImpl.class);
+  
+  /**
+   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() > fConfig.getMaxFileUploadRequestSize(); 
+    }
+    else {
+      result = aRequestParser.getRequest().getContentLength() > fConfig.getMaxHttpRequestSize(); 
+    }
+    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( fConfig.getSpamDetectionInFirewall() ){
+          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);
+  }
+}