View Javadoc
1   /**
2    * This file Copyright (c) 2018 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.cms.security;
35  
36  import info.magnolia.audit.AuditLoggingUtil;
37  import info.magnolia.cms.filters.AbstractMgnlFilter;
38  import info.magnolia.context.Context;
39  
40  import java.io.IOException;
41  import java.security.SecureRandom;
42  import java.util.Arrays;
43  import java.util.Base64;
44  import java.util.Optional;
45  import java.util.Random;
46  import java.util.Objects;
47  
48  import javax.inject.Inject;
49  import javax.inject.Provider;
50  import javax.servlet.FilterChain;
51  import javax.servlet.ServletException;
52  import javax.servlet.http.Cookie;
53  import javax.servlet.http.HttpServletRequest;
54  import javax.servlet.http.HttpServletResponse;
55  import javax.servlet.http.HttpSession;
56  
57  import org.apache.commons.lang3.StringUtils;
58  import org.slf4j.Logger;
59  import org.slf4j.LoggerFactory;
60  
61  /**
62   * Filter that handles setup and validation of tokens to prevent CSRF attacks.
63   *
64   * This provides additional layer of security in addition to Referrer-checking {@link CsrfSecurityFilter}.
65   *
66   * <p>This filter passes if:</p>
67   * <ul>
68   * <li>the method is not POST</li>
69   * <li>when user is NOT logged in, CSRF token passed as request parameter matches the value of CSRF token temporarily stored in a cookie.</li>
70   * <li>when user is logged in, CSRF token passed as request parameter matches the value of CSRF token stored in session.</li>
71   * </ul>
72   *
73   * <p>To provide flexibility, check is performed with voter in the filters bypasses node.
74   * The default bypass configured is:</p>
75   * <ul>
76   * <li>Bypass any request url that starts with '/.'.</li>
77   * </ul>
78   *
79   * <p>To add more bypasses (i.e. to 'white-list' specific referrer domains or uris) use for example:</p>
80   * <ul>
81   * <li>{@link info.magnolia.voting.voters.RequestHeaderPatternSimpleVoter} or</li>
82   * <li>{@link info.magnolia.voting.voters.RequestHeaderPatternRegexVoter}.</li>
83   * </ul>
84   *
85   * @see <a href="https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet">Cross-Site
86   *      Request Forgery (CSRF) Prevention Cheat Sheet</a>
87   */
88  public class CsrfTokenSecurityFilter extends AbstractMgnlFilter {
89  
90      private static final Logger log = LoggerFactory.getLogger(CsrfTokenSecurityFilter.class);
91  
92      static final String CSRF_ATTRIBUTE_NAME = "csrf";
93      private static final String EVENT_TYPE = "Possible CSRF Attack";
94  
95      private final Random random = new SecureRandom();
96  
97      private final Provider<Context> contextProvider;
98  
99      @Inject
100     public CsrfTokenSecurityFilter(final Provider<Context> contextProvider) {
101         this.contextProvider = contextProvider;
102     }
103 
104     @Override
105     public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
106         if (csrfCheckPasses(request, response)) {
107             chain.doFilter(request, response);
108         }
109     }
110 
111     /**
112      * @return true if csrf checks for this request pass, false otherwise.
113      */
114     protected boolean csrfCheckPasses(HttpServletRequest request, HttpServletResponse response) throws IOException {
115         HttpSession session = request.getSession(false);
116         return unloggedRequestCheckPasses(request, response, session) || loggedInRequestCheckPasses(request, response, session);
117     }
118 
119     /**
120      * Handles a request issued by a logged in user. Session has been created, token is stored in it and matched against
121      * the token coming as a request parameter.
122      *
123      * @return true if csrf checks pass, false otherwise.
124      */
125     private boolean loggedInRequestCheckPasses(HttpServletRequest request, HttpServletResponse response, HttpSession session) throws IOException {
126         if (session != null) {
127             // user is logged in, check token
128             if (request.getMethod().equals("POST") && !session.isNew()) {
129                 String token = request.getParameter(CSRF_ATTRIBUTE_NAME);
130                 if (StringUtils.isBlank(token)) {
131                     csrfTokenMissing(request, response, request.getServletPath());
132                     return false;
133                 }
134                 if (!token.equals(session.getAttribute(CSRF_ATTRIBUTE_NAME))) {
135                     csrfTokenMismatch(request, response, request.getServletPath());
136                     return false;
137                 }
138                 // session is new, user has just logged in, put token in session
139             } else if (session.getAttribute(CSRF_ATTRIBUTE_NAME) == null) {
140                 session.setAttribute(CSRF_ATTRIBUTE_NAME, generateSafeToken());
141             }
142             return true;
143         }
144         return false;
145     }
146 
147     /**
148      * Handles a request issued at login page. Session has not been created yet, token is temporarily kept in a cookie on the client.
149      * Cookies act here as a sort of pre-session.
150      * <p>Token is generated anew at each GET request. Upon POSTing, when the login form is actually submitted to the server, cookie token
151      * is matched against the value coming from request. Token is stored in a cookie in un-encrypted form, which is could/should be changed
152      * in favor of encrypted form for the sake of protecting from the cases of potentially compromised sub-domains that may tamper with
153      * the cookies.
154      *
155      * <p>This method is using a stateless technique, the so-called "Double Submit Cookie". See more at
156      * https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
157      *
158      * @return true if csrf checks pass, false otherwise.
159      */
160     private boolean unloggedRequestCheckPasses(HttpServletRequest request, HttpServletResponse response, HttpSession session) throws IOException {
161         if (session == null) {
162             // login page is requested, set cookie and token
163             if (request.getMethod().equals("GET")) {
164                 String token = generateSafeToken();
165                 request.setAttribute(CSRF_ATTRIBUTE_NAME, token);
166 
167                 Cookie cookie = new Cookie(CSRF_ATTRIBUTE_NAME, token);
168                 cookie.setPath(request.getServletPath());
169                 // A negative value means that the cookie is not stored persistently
170                 cookie.setMaxAge(-1);
171                 response.addCookie(cookie);
172 
173                 // login attempt, check token and cookie
174             } else if (request.getMethod().equals("POST")) {
175                 String token = request.getParameter(CSRF_ATTRIBUTE_NAME);
176                 Optional<Cookie> cookie = Arrays.stream(request.getCookies())
177                         .filter(c -> c.getName().equals(CSRF_ATTRIBUTE_NAME))
178                         .findFirst();
179 
180                 if (StringUtils.isNotBlank(token) && cookie.isPresent()) {
181                     if (!Objects.equals(token, cookie.get().getValue())) {
182                         csrfTokenMismatch(request, response, request.getServletPath());
183                         return false;
184                     }
185                 } else {
186                     csrfTokenMissing(request, response, request.getServletPath());
187                     return false;
188                 }
189             }
190             return true;
191         }
192         return false;
193     }
194 
195     private void csrfTokenMissing(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
196         final String auditDetails = String.format("CSRF token not set while user '%s' attempted to access url '%s'.", contextProvider.get().getUser().getName(), url);
197         handleError(request, response, auditDetails);
198     }
199 
200     private void csrfTokenMismatch(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
201         final String auditDetails = String.format("CSRF token mismatched while user '%s' attempted to access url '%s'.", contextProvider.get().getUser().getName(), url);
202         handleError(request, response, auditDetails);
203     }
204 
205     /**
206      * Actions to take when a CSRF attack is detected.
207      * Log a message and send {@link HttpServletResponse#SC_FORBIDDEN} error response.
208      */
209     protected void handleError(HttpServletRequest request, HttpServletResponse response, String message) throws IOException {
210         auditLogging(request, response, message);
211         response.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF token mismatch possibly caused by expired session. Please re-open the page and submit the form again.");
212     }
213 
214     private void auditLogging(HttpServletRequest request, HttpServletResponse response, String auditDetails) throws IOException {
215         log.warn("{}. {}", new Object[]{EVENT_TYPE, auditDetails});
216         AuditLoggingUtil.logSecurity(request.getRemoteAddr(), EVENT_TYPE, auditDetails);
217     }
218 
219     private String generateSafeToken() {
220         byte bytes[] = new byte[20];
221         random.nextBytes(bytes);
222         Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
223         String token = encoder.encodeToString(bytes);
224         return token;
225     }
226 
227 }