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  
47  import javax.inject.Inject;
48  import javax.inject.Provider;
49  import javax.servlet.FilterChain;
50  import javax.servlet.ServletException;
51  import javax.servlet.http.Cookie;
52  import javax.servlet.http.HttpServletRequest;
53  import javax.servlet.http.HttpServletResponse;
54  import javax.servlet.http.HttpSession;
55  
56  import org.apache.commons.lang3.StringUtils;
57  import org.slf4j.Logger;
58  import org.slf4j.LoggerFactory;
59  
60  /**
61   * Filter that handles setup and validation of tokens to prevent CSRF attacks.
62   *
63   * This provides additional layer of security in addition to Referrer-checking {@link CsrfSecurityFilter}.
64   *
65   * <p>This filter passes if:</p>
66   * <ul>
67   * <li>the method is not POST</li>
68   * <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>
69   * <li>when user is logged in, CSRF token passed as request parameter matches the value of CSRF token stored in session.</li>
70   * </ul>
71   *
72   * <p>To provide flexibility, check is performed with voter in the filters bypasses node.
73   * The default bypass configured is:</p>
74   * <ul>
75   * <li>Bypass any request url that starts with '/.'.</li>
76   * </ul>
77   *
78   * <p>To add more bypasses (i.e. to 'white-list' specific referrer domains or uris) use for example:</p>
79   * <ul>
80   * <li>{@link info.magnolia.voting.voters.RequestHeaderPatternSimpleVoter} or</li>
81   * <li>{@link info.magnolia.voting.voters.RequestHeaderPatternRegexVoter}.</li>
82   * </ul>
83   *
84   * @see <a href="https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet">Cross-Site
85   *      Request Forgery (CSRF) Prevention Cheat Sheet</a>
86   */
87  public class CsrfTokenSecurityFilter extends AbstractMgnlFilter {
88  
89      private static final Logger log = LoggerFactory.getLogger(CsrfTokenSecurityFilter.class);
90  
91      static final String CSRF_ATTRIBUTE_NAME = "csrf";
92      private static final String EVENT_TYPE = "Possible CSRF Attack";
93  
94      private Random random = new SecureRandom();
95  
96      private final Provider<Context> contextProvider;
97  
98      @Inject
99      public CsrfTokenSecurityFilter(final Provider<Context> contextProvider) {
100         this.contextProvider = contextProvider;
101     }
102 
103     @Override
104     public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
105         if (csrfCheckPasses(request, response)) {
106             chain.doFilter(request, response);
107         }
108     }
109 
110     /**
111      * @return true if csrf checks for this request pass, false otherwise.
112      */
113     protected boolean csrfCheckPasses(HttpServletRequest request, HttpServletResponse response) throws IOException {
114         HttpSession session = request.getSession(false);
115         return unloggedRequestCheckPasses(request, response, session) || loggedInRequestCheckPasses(request, response, session);
116     }
117 
118     /**
119      * Handles a request issued by a logged in user. Session has been created, token is stored in it and matched against
120      * the token coming as a request parameter.
121      *
122      * @return true if csrf checks pass, false otherwise.
123      */
124     private boolean loggedInRequestCheckPasses(HttpServletRequest request, HttpServletResponse response, HttpSession session) throws IOException {
125         if (session != null) {
126             // user is logged in, check token
127             if (request.getMethod().equals("POST") && !session.isNew()) {
128                 String token = request.getParameter(CSRF_ATTRIBUTE_NAME);
129                 if (StringUtils.isBlank(token)) {
130                     csrfTokenMissing(request, response, request.getServletPath());
131                     return false;
132                 }
133                 if (!token.equals(session.getAttribute(CSRF_ATTRIBUTE_NAME))) {
134                     csrfTokenMismatch(request, response, request.getServletPath());
135                     return false;
136                 }
137                 // session is new, user has just logged in, put token in session
138             } else if (session.getAttribute(CSRF_ATTRIBUTE_NAME) == null) {
139                 session.setAttribute(CSRF_ATTRIBUTE_NAME, generateSafeToken());
140             }
141             return true;
142         }
143         return false;
144     }
145 
146     /**
147      * Handles a request issued at login page. Session has not been created yet, token is temporarily kept in a cookie on the client.
148      * Cookies act here as a sort of pre-session.
149      * <p>Token is generated anew at each GET request. Upon POSTing, when the login form is actually submitted to the server, cookie token
150      * is matched against the value coming from request. To enhance security, a salted-hash is used for the cookie, so that an attacker won't
151      * be able to recreate the cookie value from the plain token without knowledge of the server secrets.
152      * <p>This method is using a stateless technique, the so-called "Double Submit Cookie". See more at
153      * https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
154      *
155      * @return true if csrf checks pass, false otherwise.
156      */
157     private boolean unloggedRequestCheckPasses(HttpServletRequest request, HttpServletResponse response, HttpSession session) throws IOException {
158         if (session == null) {
159             // login page is requested, set cookie and token
160             if (request.getMethod().equals("GET")) {
161                 String token = generateSafeToken();
162                 request.setAttribute(CSRF_ATTRIBUTE_NAME, token);
163 
164                 Cookie cookie = new Cookie(CSRF_ATTRIBUTE_NAME, SecurityUtil.getBCrypt(token));
165                 cookie.setPath(request.getServletPath());
166                 // A negative value means that the cookie is not stored persistently
167                 cookie.setMaxAge(-1);
168                 response.addCookie(cookie);
169 
170                 // login attempt, check token and cookie
171             } else if (request.getMethod().equals("POST")) {
172                 String token = request.getParameter(CSRF_ATTRIBUTE_NAME);
173                 Optional<Cookie> cookie = Arrays.stream(request.getCookies())
174                         .filter(c -> c.getName().equals(CSRF_ATTRIBUTE_NAME))
175                         .findFirst();
176 
177                 if (StringUtils.isNotBlank(token) && cookie.isPresent()) {
178                     if (!SecurityUtil.matchBCrypted(token, cookie.get().getValue())) {
179                         csrfTokenMismatch(request, response, request.getServletPath());
180                         return false;
181                     }
182                 } else {
183                     csrfTokenMissing(request, response, request.getServletPath());
184                     return false;
185                 }
186             }
187             return true;
188         }
189         return false;
190     }
191 
192     private void csrfTokenMissing(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
193         final String auditDetails = String.format("CSRF token not set while user '%s' attempted to access url '%s'.", contextProvider.get().getUser().getName(), url);
194         handleError(request, response, auditDetails);
195     }
196 
197     private void csrfTokenMismatch(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
198         final String auditDetails = String.format("CSRF token mismatched while user '%s' attempted to access url '%s'.", contextProvider.get().getUser().getName(), url);
199         handleError(request, response, auditDetails);
200     }
201 
202     /**
203      * Actions to take when a CSRF attack is detected.
204      * Log a message and send {@link HttpServletResponse#SC_FORBIDDEN} error response.
205      */
206     protected void handleError(HttpServletRequest request, HttpServletResponse response, String message) throws IOException {
207         auditLogging(request, response, message);
208         response.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF token mismatch possibly caused by expired session. Please re-open the page and submit the form again.");
209     }
210 
211     private void auditLogging(HttpServletRequest request, HttpServletResponse response, String auditDetails) throws IOException {
212         log.warn("{}. {}", new Object[]{EVENT_TYPE, auditDetails});
213         AuditLoggingUtil.logSecurity(request.getRemoteAddr(), EVENT_TYPE, auditDetails);
214     }
215 
216     private String generateSafeToken() {
217         byte bytes[] = new byte[20];
218         random.nextBytes(bytes);
219         Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
220         String token = encoder.encodeToString(bytes);
221         return token;
222     }
223 
224 }