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