--- /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}.
+
+ <P>Upon startup, this class will inspect all {@link Action}s in the application.
+ All <tt>public static final</tt> {@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}, <span class="highlight">each {@link Action} must declare all expected request
+ parameters as a <tt>public static final</tt> {@link RequestParameter} field, in order to pass hard validation.</span>
+
+ <h3>File Upload Forms</h3>
+ 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, <em>in an unparsed form</em>.
+ 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> -
+ <em>not only for the file upload control, but for all controls in the form</em>.
+
+ <P>An elegant way around this problem involves <em>wrapping</em> the request,
+ using {@link javax.servlet.http.HttpServletRequestWrapper}, such that POSTed data is parsed and made
+ available through the usual <tt>request</tt> methods.
+ If such a wrapper is used, then file upload forms can be handled in much the same way as any other form.
+
+ <P>To indicate to this class if such a wrapper is being used for file upload requests, use the <tt>FullyValidateFileUploads</tt> setting
+ in <tt>web.xml</tt>.
+
+ <P>Settings in <tt>web.xml</tt> affecting this class :
+ <ul>
+ <li><tt>MaxHttpRequestSize</tt>
+ <li><tt>MaxFileUploadRequestSize</tt>
+ <li><tt>MaxRequestParamValueSize</tt> (used by {@link hirondelle.web4j.request.RequestParameter})
+ <li><tt>SpamDetectionInFirewall</tt>
+ <li><tt>FullyValidateFileUploads</tt>
+ </ul>
+
+ <P>The above settings control the validations performed by this class :
+ <table border="1" cellpadding="3" cellspacing="0">
+ <tr>
+ <th>Check</th>
+ <th>Regular</th>
+ <th>File Upload (Wrapped)</th>
+ <th>File Upload</th>
+ </tr>
+ <tr>
+ <td>Overall request size <= <tt>MaxHttpRequestSize</tt> </td>
+ <td>Y</td>
+ <td>N</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>Overall request size <= <tt>MaxFileUploadRequestSize</tt> </td>
+ <td>N</td>
+ <td>Y</td>
+ <td>Y</td>
+ </tr>
+ <tr>
+ <td>Every param <em>name</em> is among the {@link hirondelle.web4j.request.RequestParameter}s for that {@link Action}</td>
+ <td>Y</td>
+ <td>Y*</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>Every param <em>value</em> satifies {@link hirondelle.web4j.request.RequestParameter#isValidParamValue(String)}</td>
+ <td>Y</td>
+ <td>Y**</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>If created with {@link hirondelle.web4j.request.RequestParameter#withLengthCheck(String)}, then param value size <= <tt>MaxRequestParamValueSize</tt></td>
+ <td>Y</td>
+ <td>Y**</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>If <tt>SpamDetectionInFirewall</tt> is on, then each param value is checked using the configured {@link hirondelle.web4j.security.SpamDetector}</td>
+ <td>Y</td>
+ <td>Y**</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <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>
+ <td>Y</td>
+ <td>Y</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td><a href='#CSRF'>CSRF Defenses</a></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>N</td>
+ </tr>
+ </table>
+ * For file upload controls, the param name is checked only if the return value of <tt>getParameterNames()</tt> (for the wrapper) includes it.
+ <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>
+
+ <a name='CSRF'></a>
+<h3>Defending Against CSRF Attacks</h3>
+ If the usual WEB4J defenses against CSRF attacks are active (see package-level comments),
+ 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 :
+<ul>
+ <li>validate that a request parameter named
+ {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY} is present. (This
+ request parameter is deemed to be a special 'internal' parameter, and does not need to be explicitly declared in
+ your <tt>Action</tt> like other request parameters.)
+ <li>validate that its value matches items stored in session scope. First check versus an item stored
+ under the key {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY}; if that check fails, then
+ check versus an item stored under the key
+ {@link hirondelle.web4j.security.CsrfFilter#PREVIOUS_FORM_SOURCE_ID_KEY}
+</ul>
+
+ 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.
+
+ <P>See class description for more information.
+
+ <P>Subclasses may extend this implementation, following the form :
+ <PRE>
+ 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
+ }
+ </PRE>
+ */
+ 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.
+
+ <P>Key - class object
+ <br>Value - Set of RequestParameter objects; may be empty, but not null.
+
+ <P>This is a mutable object field, but is not modified after startup, so this class is thread-safe.
+ */
+ private static Map<Class<Action>, Set<RequestParameter>> fExpectedParams = new LinkedHashMap<Class<Action>, Set<RequestParameter>>();
+
+ /** 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<RequestParameter> 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<SafeText> 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 <tt>null</tt>. Matches to both regular and 'internal' request params. */
+ private RequestParameter matchToKnownParam(String aIncomingParamName, Collection<RequestParameter> 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<SafeText> 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<RequestParameter> 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 <FORM> 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);
+ }
+}