001    package hirondelle.web4j.security;
002    
003    import java.util.*;
004    import java.util.logging.Logger;
005    import javax.servlet.ServletConfig;
006    import javax.servlet.http.HttpServletResponse;
007    import javax.servlet.http.HttpSession;
008    
009    import hirondelle.web4j.BuildImpl;
010    import hirondelle.web4j.action.Action;
011    import hirondelle.web4j.model.BadRequestException;
012    import hirondelle.web4j.readconfig.ConfigReader;
013    import hirondelle.web4j.readconfig.InitParam;
014    import hirondelle.web4j.request.RequestParameter;
015    import hirondelle.web4j.request.RequestParser;
016    import hirondelle.web4j.util.Util;
017    import hirondelle.web4j.action.Operation;
018    import hirondelle.web4j.model.Id;
019    import static hirondelle.web4j.util.Consts.FAILS;
020    
021    /**
022     Default implementation of {@link ApplicationFirewall}.
023    
024     <P>Upon startup, this class will inspect all {@link Action}s in the application. 
025     All <tt>public static final</tt> {@link hirondelle.web4j.request.RequestParameter} fields accessible 
026     to each {@link Action} will be collected, and treated here as the set of acceptable 
027     {@link RequestParameter}s for each {@link Action} class. Thus, when this class is used to implement 
028     {@link ApplicationFirewall},  <span class="highlight">each {@link Action} must declare all expected request 
029     parameters as a <tt>public static final</tt> {@link RequestParameter} field, in order to pass hard validation.</span> 
030    
031     <h3>File Upload Forms</h3>
032     If a POSTed request includes one or more file upload controls, then the underlying HTTP request has 
033     a completely different structure from a regular request having no file upload controls.
034     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>. 
035     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> -  
036     <em>not only for the file upload control, but for all controls in the form</em>. 
037     
038     <P>An elegant way around this problem involves <em>wrapping</em> the request, 
039     using {@link javax.servlet.http.HttpServletRequestWrapper}, such that POSTed data is parsed and made 
040     available through the usual <tt>request</tt> methods.
041     If such a wrapper is used, then file upload forms can be handled in much the same way as any other form.
042      
043     <P>To indicate to this class if such a wrapper is being used for file upload requests, use the <tt>FullyValidateFileUploads</tt> setting 
044     in <tt>web.xml</tt>. 
045    
046     <P>Settings in <tt>web.xml</tt> affecting this class :
047     <ul>
048     <li><tt>MaxHttpRequestSize</tt>
049     <li><tt>MaxFileUploadRequestSize</tt>
050     <li><tt>MaxRequestParamValueSize</tt> (used by {@link hirondelle.web4j.request.RequestParameter})
051     <li><tt>SpamDetectionInFirewall</tt>
052     <li><tt>FullyValidateFileUploads</tt>
053     </ul>
054     
055     <P>The above settings control the validations performed by this class :
056     <table   border="1" cellpadding="3" cellspacing="0">
057      <tr>
058       <th>Check</th>
059       <th>Regular</th>
060       <th>File Upload (Wrapped)</th>
061       <th>File Upload</th>
062      </tr>
063      <tr>
064       <td>Overall request size &lt;= <tt>MaxHttpRequestSize</tt> </td>
065       <td>Y</td>
066       <td>N</td>
067       <td>N</td>
068      </tr>
069      <tr>
070       <td>Overall request size &lt;= <tt>MaxFileUploadRequestSize</tt> </td>
071       <td>N</td>
072       <td>Y</td>
073       <td>Y</td>
074      </tr>
075      <tr>
076       <td>Every param <em>name</em> is among the {@link hirondelle.web4j.request.RequestParameter}s  for that {@link Action}</td>
077       <td>Y</td>
078       <td>Y&#042;</td>
079       <td>N</td>
080      </tr>
081      <tr>
082       <td>Every param <em>value</em> satifies {@link hirondelle.web4j.request.RequestParameter#isValidParamValue(String)}</td>
083       <td>Y</td>
084       <td>Y&#042;&#042;</td>
085       <td>N</td>
086      </tr>
087      <tr>
088       <td>If created with {@link hirondelle.web4j.request.RequestParameter#withLengthCheck(String)}, then param value size &lt;= <tt>MaxRequestParamValueSize</tt></td>
089       <td>Y</td>
090       <td>Y&#042;&#042;</td>
091       <td>N</td>
092      </tr>
093      <tr>
094       <td>If <tt>SpamDetectionInFirewall</tt> is on, then each param value is checked using the configured {@link hirondelle.web4j.security.SpamDetector}</td>
095       <td>Y</td>
096       <td>Y&#042;&#042;</td>
097       <td>N</td>
098      </tr>
099      <tr>
100       <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>
101       <td>Y</td>
102       <td>Y</td>
103       <td>N</td>
104      </tr>
105      <tr>
106       <td><a href='#CSRF'>CSRF Defenses</a></td>
107       <td>Y</td>
108       <td>Y</td>
109       <td>N</td>
110      </tr>
111     </table>
112     &#042; For file upload controls, the param name is checked only if the return value of <tt>getParameterNames()</tt> (for the wrapper) includes it.
113     <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>
114     
115     <a name='CSRF'></a>
116    <h3>Defending Against CSRF Attacks</h3>
117     If the usual WEB4J defenses against CSRF attacks are active (see package-level comments), 
118     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 :
119    <ul>
120     <li>validate that a request parameter named 
121     {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY} is present. (This
122     request parameter is deemed to be a special 'internal' parameter, and does not need to be explicitly declared in 
123     your <tt>Action</tt> like other request parameters.)
124     <li>validate that its value matches items stored in session scope. First check versus an item stored  
125     under the key {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY}; if that check fails, then 
126     check versus an item stored under the key 
127     {@link hirondelle.web4j.security.CsrfFilter#PREVIOUS_FORM_SOURCE_ID_KEY}
128    </ul>
129    
130     See {@link hirondelle.web4j.security.CsrfFilter} for more information.
131    */
132    public class ApplicationFirewallImpl implements ApplicationFirewall {
133      
134      /** 
135       Called by {@link hirondelle.web4j.request.RequestParser} to initialize this class upon startup.
136       
137       <P>Inspects all {@link Action} classes to find the {@link RequestParameter}s expected by each one.
138       
139       <P>Extracts these settings from <tt>web.xml</tt>  :
140       
141       <table  border="1" cellpadding="3" cellspacing="0">
142        <tr><th>Name</th><th>Default Value</th></tr>
143        <tr><td>MaxHttpRequestSize</td><td>51200</td></tr>
144        <tr><td>MaxFileUploadRequestSize</td><td>51200</td></tr>
145        <tr><td>SpamDetectionInFirewall</td><td>OFF</td></tr>
146        <tr><td>FullyValidateFileUploads</td><td>OFF</td></tr>
147       </table>  
148      */
149      public static void init(ServletConfig aConfig){
150        fMaxRequestSize = getMaxSize(fMAX_REQUEST_SIZE, aConfig);
151        fMaxFileUploadRequestSize = getMaxSize(fMAX_FILE_UPLOAD_REQUEST_SIZE, aConfig);
152        fFullyValidateFileUploads = getBooleanSetting(fFULLY_VALIDATE_FILE_UPLOADS, aConfig);
153        fIsSpamDetectionOn = getBooleanSetting(fIS_SPAM_DETECTION_ON, aConfig);
154        mapActionsToExpectedParams();
155      }
156      
157      /**
158       Perform checks on the incoming request. 
159       
160       <P>See class description for more information.
161       
162       <P>Subclasses may extend this implementation, following the form :
163       <PRE>
164      public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
165        super(aAction, aRequestParser);
166        //place additional validations here
167        //for example, one might check that a Content-Length header is present,
168        //or that all header values are within some size range
169      }
170       </PRE>
171      */
172      public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
173        if( aRequestParser.isFileUploadRequest() ){
174          fLogger.fine("Validating a file upload request.");
175        }
176        checkForExtremeSize(aRequestParser);
177        if ( aRequestParser.isFileUploadRequest() && ! fFullyValidateFileUploads )  {
178          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.");
179        }
180        else {
181          checkParamNamesAndValues(aAction, aRequestParser);
182          checkSideEffectOperations(aAction, aRequestParser);
183          defendAgainstCSRFAttacks(aRequestParser);
184        }
185      }
186      
187      // PRIVATE //
188      
189      /**
190       Maps {@link Action} classes to a List of expected {@link hirondelle.web4j.request.RequestParameter} objects.
191       If the incoming request contains a request parameter whose name or value is not consistent with this 
192       list, then a {@link hirondelle.web4j.model.BadRequestException} is thrown.
193       
194       <P>Key - class object
195       <br>Value - Set of RequestParameter objects; may be empty, but not null.
196       
197       <P>This is a mutable object field, but is not modified after startup, so this class is thread-safe.
198      */
199      private static Map<Class<Action>, Set<RequestParameter>> fExpectedParams = new LinkedHashMap<Class<Action>, Set<RequestParameter>>();
200      
201      /** Max size of a valid HTTP request, in bytes. Pulled in from web.xml.  */
202      private static int fMaxRequestSize;
203      private static final InitParam fMAX_REQUEST_SIZE = new InitParam("MaxHttpRequestSize", "51200");
204    
205      /** Max size in bytes of an HTTP request containing a file upload. Pulled in from web.xml.  */
206      private static int fMaxFileUploadRequestSize;
207      private static final InitParam fMAX_FILE_UPLOAD_REQUEST_SIZE = new InitParam("MaxFileUploadRequestSize", "51200");
208    
209      /** Indicate if file upload request can be validated in detail. Pulled in from web.xml.  */
210      private static boolean fFullyValidateFileUploads;
211      private static final InitParam fFULLY_VALIDATE_FILE_UPLOADS = new InitParam("FullyValidateFileUploads", "OFF");
212      
213      /** Toggle for attempting detection of spam. Pulled in from web.xml. */
214      private static boolean fIsSpamDetectionOn;
215      private static final InitParam fIS_SPAM_DETECTION_ON = new InitParam("SpamDetectionInFirewall", "OFF");
216      
217      private static final String CURRENT_TOKEN_CSRF = CsrfFilter.FORM_SOURCE_ID_KEY;
218      private static final String PREVIOUS_TOKEN_CSRF = CsrfFilter.PREVIOUS_FORM_SOURCE_ID_KEY;
219    
220      /**
221       Special, 'internal' request parameter, used by the framework to defend against CSRF attacks.
222      */
223      private static final RequestParameter fCSRF_REQ_PARAM = RequestParameter.withLengthCheck(CsrfFilter.FORM_SOURCE_ID_KEY);
224      
225      private static final Logger fLogger = Util.getLogger(ApplicationFirewallImpl.class);
226      
227      private static int getMaxSize(InitParam aInitParam, ServletConfig aConfig){
228        int result = Integer.parseInt(
229          aInitParam.fetch(aConfig).getValue()
230        );
231        if ( result < 1000 ) {
232          throw new IllegalArgumentException(
233            "Configured value of " + result + " in web.xml for " + 
234            aInitParam.getName() + 
235            " is too low. Please see web.xml for more information." 
236          );
237        }
238        return result;
239      }
240      
241      private static boolean getBooleanSetting(InitParam aInitParam, ServletConfig aConfig){
242        boolean result = false;
243        String value = aInitParam.fetch(aConfig).getValue().trim();
244        if( "ON".equalsIgnoreCase(value) ) {
245          result = true;    
246        }
247        else if ("OFF".equalsIgnoreCase(value)){
248          //default
249        }
250        else {
251          throw new IllegalArgumentException(
252            "Configured value of " + Util.quote(value) + " in web.xml for " + 
253            aInitParam.getName() + 
254            " is not among the expected values. Please see web.xml for more information." 
255          );
256        }
257        return result;
258      }
259      
260      
261      private static void mapActionsToExpectedParams(){
262        fExpectedParams = ConfigReader.fetchPublicStaticFinalFields(Action.class, RequestParameter.class);
263        fLogger.config("Expected Request Parameters per Web Action." + Util.logOnePerLine(fExpectedParams));
264      }
265      
266      /**
267       Some denial-of-service attacks place large amounts of data in the request 
268       params, in an attempt to overload the server. This method will check for 
269       such requests. This check must be performed first, before any further 
270       processing is attempted.
271      */
272      private void checkForExtremeSize(RequestParser aRequest) throws BadRequestException {
273        fLogger.fine("Checking for extreme size.");
274        if ( isRequestExcessivelyLarge(aRequest) ) {
275          throw new BadRequestException(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
276        }
277      }
278    
279      private boolean isRequestExcessivelyLarge(RequestParser aRequestParser){
280        boolean result = false;
281        if ( aRequestParser.isFileUploadRequest() ) {
282          result = aRequestParser.getRequest().getContentLength() > fMaxFileUploadRequestSize; 
283        }
284        else {
285          result = aRequestParser.getRequest().getContentLength() > fMaxRequestSize; 
286        }
287        return result;
288      }
289       
290      void checkParamNamesAndValues(Action aAction, RequestParser aRequestParser) throws BadRequestException {
291        if ( fExpectedParams.containsKey(aAction.getClass()) ){
292          Set<RequestParameter> expectedParams = fExpectedParams.get(aAction.getClass());
293          //this method may return file upload controls - depends on interpretation, whether to include file upload controls in this method
294          Enumeration paramNames = aRequestParser.getRequest().getParameterNames();
295          while ( paramNames.hasMoreElements() ){
296            String incomingParamName = (String)paramNames.nextElement();
297            fLogger.fine("Checking parameter named " + Util.quote(incomingParamName));
298            RequestParameter knownParam = matchToKnownParam(incomingParamName, expectedParams);
299            if( knownParam == null ){
300              fLogger.severe("*** Unknown Parameter *** : " + Util.quote(incomingParamName) + ". Please add public static final RequestParameter field for this item to your Action.");
301              throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
302            }
303            if ( knownParam.isFileUploadParameter() ) {
304              fLogger.fine("File Upload parameter - value not validatable here: " + knownParam.getName());
305              continue; //prevents checks on values for file upload controls
306            }
307            Collection<SafeText> paramValues = aRequestParser.toSafeTexts(knownParam);
308            if( ! isInternalParam( knownParam) ) {
309              checkParamValues(knownParam, paramValues);
310            }
311          }
312        }
313        else {
314          String message = "Action " + aAction.getClass() + " not known to ApplicationFirewallImpl.";
315          fLogger.severe(message);
316          //this is NOT a BadRequestEx, since not outside the control of this framework.
317          throw new RuntimeException(message);
318        }
319      }
320      
321      /** If no match is found, return <tt>null</tt>.   Matches to both regular and 'internal' request params. */
322      private RequestParameter matchToKnownParam(String aIncomingParamName, Collection<RequestParameter> aExpectedParams){
323        RequestParameter result = null;
324        for (RequestParameter reqParam: aExpectedParams){
325          if ( reqParam.getName().equals(aIncomingParamName) ){
326            result = reqParam;
327            break;
328          }
329        }
330        if( result == null && fCSRF_REQ_PARAM.getName().equals(aIncomingParamName) ){
331          result = fCSRF_REQ_PARAM;
332        }
333        return result;
334      }
335      
336      private void checkParamValues(RequestParameter aKnownReqParam, Collection<SafeText> aParamValues) throws BadRequestException {
337        for(SafeText paramValue: aParamValues){
338          if ( Util.textHasContent(paramValue) ) {
339            if ( ! aKnownReqParam.isValidParamValue(paramValue.getRawString()) ) {
340              fLogger.severe("Request parameter named " + aKnownReqParam.getName() + " has an invalid value.");
341              throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
342            }
343            if( fIsSpamDetectionOn ){
344              SpamDetector spamDetector = BuildImpl.forSpamDetector();
345              if( spamDetector.isSpam(paramValue.getRawString()) ){
346                fLogger.fine("SPAM detected.");
347                throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
348              }
349            }
350          }
351        }
352      }
353      
354      private void checkSideEffectOperations(Action aAction, RequestParser aRequestParser) throws BadRequestException {
355        fLogger.fine("Checking for side-effect operations.");
356        Set<RequestParameter> expectedParams = fExpectedParams.get(aAction.getClass());
357        for (RequestParameter reqParam : expectedParams){
358          if ( "Operation".equals(reqParam.getName()) ){
359            String rawValue = aRequestParser.getRawParamValue(reqParam);
360            if (Util.textHasContent(rawValue)){
361              Operation operation = Operation.valueOf(rawValue);
362              if ( isAttemptingSideEffectOperationWithoutPOST(operation, aRequestParser) ){
363                fLogger.severe("Security problem. Attempted operation having side effects outside of a POST. Please use a <FORM> with method='POST'.");
364                throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
365              }
366            }
367          }
368        }
369      }
370      
371      private boolean isAttemptingSideEffectOperationWithoutPOST(Operation aOperation, RequestParser aRequestParser){
372        return aOperation.hasSideEffects() && !aRequestParser.getRequest().getMethod().equals("POST");
373      }
374    
375      /**
376       An internal request param is not declared explicitly by the application programmer. Rather, it is defined and 
377       used only by the framework.
378      */
379      private boolean  isInternalParam(RequestParameter aRequestParam) {
380        return aRequestParam.getName().equals(fCSRF_REQ_PARAM.getName());
381      }
382    
383      private void defendAgainstCSRFAttacks(RequestParser aRequestParser) throws BadRequestException {
384        if( requestNeedsDefendingAgainstCSRFAttacks(aRequestParser) ) {
385          Id postedTokenValue = aRequestParser.toId(fCSRF_REQ_PARAM);
386          if ( FAILS == toIncludeCsrfTokenWithForm(postedTokenValue) ){
387            fLogger.severe("CSRF token not included in POSTed request. Rejecting this request, since it is likely an attack.");
388            throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
389          }
390          
391          if( FAILS == matchCurrentCSRFToken(aRequestParser, postedTokenValue) ) {
392            if( FAILS == matchPreviousCSRFToken(aRequestParser, postedTokenValue) ) {
393              fLogger.severe("CSRF token does not match the expected value. Rejecting this request, since it is likely an attack.");
394              throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);        
395            }
396          }
397          fLogger.fine("Success: no CSRF problem detected.");
398        }
399      }
400      
401      private boolean requestNeedsDefendingAgainstCSRFAttacks(RequestParser aRequestParser){
402        boolean isPOST =  aRequestParser.getRequest().getMethod().equalsIgnoreCase("POST");
403        boolean sessionPresent = isSessionPresent(aRequestParser);
404        boolean csrfFilterIsTurnedOn = false;
405        if( sessionPresent ) {
406          Id csrfTokenInSession = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser);
407          csrfFilterIsTurnedOn = (csrfTokenInSession != null);
408        }
409        
410        if( isPOST &&  sessionPresent && ! csrfFilterIsTurnedOn )  {
411          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.");  
412        }
413        
414        boolean result =  isPOST && sessionPresent && csrfFilterIsTurnedOn;
415        fLogger.fine("Session exists, and the CsrfFilter is turned on : " + csrfFilterIsTurnedOn);
416        fLogger.fine("Does the firewall need to check this request for CSRF attacks? : " + result);
417        return result;
418      }
419      
420      private boolean toIncludeCsrfTokenWithForm(Id aCsrfToken){
421        return aCsrfToken != null;
422      }
423      
424      private boolean matchCurrentCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue) {
425        Id currentToken = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser);
426        return aPostedTokenValue.equals(currentToken);
427      }
428      
429      private boolean matchPreviousCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue){
430        //in the case of an anonymous session, with no login, this item will be null
431        Id previousToken = getCsrfTokenInSession(PREVIOUS_TOKEN_CSRF, aRequestParser);
432        return aPostedTokenValue.equals(previousToken);
433      }
434    
435      private boolean isSessionPresent(RequestParser aRequestParser){
436        boolean DO_NOT_CREATE = false;
437        HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE);
438        return session != null;
439      }
440      
441      /** Only called when session is present. No risk of null pointer exception. */
442      private Id getCsrfTokenInSession(String aKey, RequestParser aRequestParser){
443        boolean DO_NOT_CREATE = false;
444        HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE);
445        return (Id)session.getAttribute(aKey);
446      }
447    }