diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/ApplicationFirewallImpl.java --- /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}. + +

Upon startup, this class will inspect all {@link Action}s in the application. + All public static final {@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}, each {@link Action} must declare all expected request + parameters as a public static final {@link RequestParameter} field, in order to pass hard validation. + +

File Upload Forms

+ 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, in an unparsed form. + For such forms, POSTed data is not available in the usual way, and by default request.getParameter(String) will return null - + not only for the file upload control, but for all controls in the form. + +

An elegant way around this problem involves wrapping the request, + using {@link javax.servlet.http.HttpServletRequestWrapper}, such that POSTed data is parsed and made + available through the usual request methods. + If such a wrapper is used, then file upload forms can be handled in much the same way as any other form. + +

To indicate to this class if such a wrapper is being used for file upload requests, use the FullyValidateFileUploads setting + in web.xml. + +

Settings in web.xml affecting this class : +

+ +

The above settings control the validations performed by this class : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CheckRegularFile Upload (Wrapped)File Upload
Overall request size <= MaxHttpRequestSize YNN
Overall request size <= MaxFileUploadRequestSize NYY
Every param name is among the {@link hirondelle.web4j.request.RequestParameter}s for that {@link Action}YY*N
Every param value satifies {@link hirondelle.web4j.request.RequestParameter#isValidParamValue(String)}YY**N
If created with {@link hirondelle.web4j.request.RequestParameter#withLengthCheck(String)}, then param value size <= MaxRequestParamValueSizeYY**N
If SpamDetectionInFirewall is on, then each param value is checked using the configured {@link hirondelle.web4j.security.SpamDetector}YY**N
If a request param named Operation exists and it returns true for {@link Operation#hasSideEffects()}, then the underlying request must be a POSTYYN
CSRF DefensesYYN
+ * For file upload controls, the param name is checked only if the return value of getParameterNames() (for the wrapper) includes it. +
**Except for file upload controls. For file upload controls, no checks on the param value are made by this class.
+ + +

Defending Against CSRF Attacks

+ If the usual WEB4J defenses against CSRF attacks are active (see package-level comments), + then for every POST request executed within a session the following will also be performed as a defense against CSRF attacks : + + + 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. + +

See class description for more information. + +

Subclasses may extend this implementation, following the form : +

+  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
+  }
+   
+ */ + 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. + +

Key - class object +
Value - Set of RequestParameter objects; may be empty, but not null. + +

This is a mutable object field, but is not modified after startup, so this class is thread-safe. + */ + private static Map, Set> fExpectedParams = new LinkedHashMap, Set>(); + + /** 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 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 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 null. Matches to both regular and 'internal' request params. */ + private RequestParameter matchToKnownParam(String aIncomingParamName, Collection 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 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 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

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); + } +}