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