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 <= <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 <= <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*</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**</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 <= <tt>MaxRequestParamValueSize</tt></td> 094 <td>Y</td> 095 <td>Y**</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**</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 * 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>**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 }