001/*
002 * (C) Copyright 2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.platform.web.common.requestcontroller.filter;
020
021import static com.google.common.net.HttpHeaders.ORIGIN;
022import static com.google.common.net.HttpHeaders.REFERER;
023import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
024import static javax.servlet.http.HttpServletResponse.SC_OK;
025import static org.apache.commons.lang3.StringUtils.isBlank;
026
027import java.io.IOException;
028import java.io.Serializable;
029import java.net.URI;
030import java.net.URISyntaxException;
031import java.security.SecureRandom;
032import java.util.ArrayList;
033import java.util.Arrays;
034import java.util.HashSet;
035import java.util.List;
036import java.util.Map;
037import java.util.Objects;
038import java.util.Random;
039import java.util.Set;
040
041import javax.servlet.Filter;
042import javax.servlet.FilterChain;
043import javax.servlet.FilterConfig;
044import javax.servlet.ServletException;
045import javax.servlet.ServletRequest;
046import javax.servlet.ServletResponse;
047import javax.servlet.http.HttpServletRequest;
048import javax.servlet.http.HttpServletRequestWrapper;
049import javax.servlet.http.HttpServletResponse;
050import javax.servlet.http.HttpSession;
051
052import org.apache.commons.lang3.RandomStringUtils;
053import org.apache.commons.lang3.StringUtils;
054import org.apache.commons.logging.Log;
055import org.apache.commons.logging.LogFactory;
056import org.apache.http.client.methods.HttpGet;
057import org.apache.http.client.methods.HttpHead;
058import org.apache.http.client.methods.HttpOptions;
059import org.apache.http.client.methods.HttpTrace;
060import org.nuxeo.ecm.platform.web.common.requestcontroller.service.RequestControllerManager;
061import org.nuxeo.ecm.platform.web.common.vh.VirtualHostHelper;
062import org.nuxeo.runtime.api.Framework;
063import org.nuxeo.runtime.services.config.ConfigurationService;
064
065import com.thetransactioncompany.cors.CORSConfiguration;
066import com.thetransactioncompany.cors.CORSFilter;
067import com.thetransactioncompany.cors.Origin;
068
069/**
070 * Nuxeo CORS and CSRF filter, returning CORS configuration and preventing CSRF attacks by rejecting dubious requests.
071 *
072 * @since 5.7.2 for CORS
073 * @since 10.1 for CSRF
074 */
075public class NuxeoCorsCsrfFilter implements Filter {
076
077    private static final Log log = LogFactory.getLog(NuxeoCorsCsrfFilter.class);
078
079    public static final String GET = HttpGet.METHOD_NAME;
080
081    public static final String HEAD = HttpHead.METHOD_NAME;
082
083    public static final String OPTIONS = HttpOptions.METHOD_NAME;
084
085    public static final String TRACE = HttpTrace.METHOD_NAME;
086
087    // safe methods according to RFC 7231 4.2.1
088    protected static final Set<String> SAFE_METHODS = new HashSet<>(Arrays.asList(GET, HEAD, OPTIONS, TRACE));
089
090    // RFC 6454
091    // 6.2 If the origin is not a scheme/host/port triple, then return the string null
092    // 7.3 Whenever a user agent issues an HTTP request from a "privacy-sensitive" context,
093    // the user agent MUST send the value "null" in the Origin header field.
094    public static final String ORIGIN_NULL = "null";
095
096    // marker for privacy-sensitive origins
097    public static final URI PRIVACY_SENSITIVE = URI.create("privacy-sensitive:///");
098
099    public static final List<String> SCHEMES_ALLOWED = Arrays.asList("moz-extension", "chrome-extension");
100
101    /**
102     * Allows to disable strict CORS checks when a request has Origin: null.
103     * <p>
104     * This may happen for local files, or for a JavaScript-triggered redirect. Setting this to false may expose the
105     * application to CSRF problems from files locally hosted on the user's disk.
106     *
107     * @since 10.3
108     */
109    public static final String ALLOW_NULL_ORIGIN_PROP = "nuxeo.cors.allowNullOrigin";
110
111    /** @since 10.3 */
112    public static final String ALLOW_NULL_ORIGIN_DEFAULT = "false";
113
114    /**
115     * Configuration property (namespace) for CSRF tokens.
116     *
117     * @since 10.3
118     */
119    public static final String CSRF_TOKEN_NS_PROP = "nuxeo.csrf.token";
120
121    /**
122     * Allows enforcing the use of a CSRF token. Configuration property (under the {@value #CSRF_TOKEN_NS_PROP}
123     * namespace).
124     *
125     * @since 10.3
126     */
127    public static final String CSRF_TOKEN_ENABLED_SUBPROP = "enabled";
128
129    /** @since 10.3 */
130    public static final String CSRF_TOKEN_ENABLED_DEFAULT = "false";
131
132    /**
133     * Allows definition of endpoints for which no CSRF token check is done. Configuration <em>list</em> property (under
134     * the {@value #CSRF_TOKEN_NS_PROP} namespace).
135     *
136     * @since 10.3
137     */
138    public static final String CSRF_TOKEN_SKIP_SUBPROP = "skip";
139
140    /**
141     * Session attribute in which token is stored.
142     *
143     * @since 10.3
144     */
145    public static final String CSRF_TOKEN_ATTRIBUTE = "NuxeoCSRFToken";
146
147    /**
148     * Request header to pass a token, or fetch one.
149     *
150     * @since 10.3
151     */
152    public static final String CSRF_TOKEN_HEADER = "CSRF-Token";
153
154    /**
155     * Pseudo-value to fetch a token.
156     *
157     * @since 10.3
158     */
159    public static final String CSRF_TOKEN_FETCH = "fetch";
160
161    /**
162     * Pseudo-value to denote an invalid token.
163     *
164     * @since 10.3
165     */
166    public static final String CSRF_TOKEN_INVALID = "invalid";
167
168    /**
169     * Request parameter to pass a token.
170     *
171     * @since 10.3
172     */
173    public static final String CSRF_TOKEN_PARAM = "csrf-token";
174
175    protected static final Random RANDOM = new SecureRandom();
176
177    protected boolean allowNullOrigin;
178
179    protected boolean csrfTokenEnabled;
180
181    protected List<String> csrfTokenSkipPaths;
182
183    @Override
184    public void init(FilterConfig filterConfig) {
185        ConfigurationService configurationService = Framework.getService(ConfigurationService.class);
186        allowNullOrigin = Boolean.parseBoolean(
187                configurationService.getProperty(ALLOW_NULL_ORIGIN_PROP, ALLOW_NULL_ORIGIN_DEFAULT));
188        Map<String, Serializable> csrfTokenConfig = configurationService.getProperties(CSRF_TOKEN_NS_PROP);
189        csrfTokenEnabled = Boolean.parseBoolean(StringUtils.defaultString(
190                (String) csrfTokenConfig.get(CSRF_TOKEN_ENABLED_SUBPROP), CSRF_TOKEN_ENABLED_DEFAULT));
191        csrfTokenSkipPaths = new ArrayList<>();
192        Serializable skipPaths = csrfTokenConfig.get(CSRF_TOKEN_SKIP_SUBPROP);
193        if (skipPaths instanceof String[]) {
194            csrfTokenSkipPaths.addAll(Arrays.asList((String[]) skipPaths));
195        }
196    }
197
198    @Override
199    public void destroy() {
200        // nothing to do
201    }
202
203    @Override
204    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
205            throws IOException, ServletException {
206        HttpServletRequest request = (HttpServletRequest) servletRequest;
207        HttpServletResponse response = (HttpServletResponse) servletResponse;
208
209        if (manageCSRFToken(request, response)) {
210            return;
211        }
212
213        RequestControllerManager service = Framework.getService(RequestControllerManager.class);
214        CORSFilter corsFilter = service.getCorsFilterForRequest(request);
215        CORSConfiguration corsConfig = corsFilter == null ? null : corsFilter.getConfiguration();
216        String method = request.getMethod();
217        URI sourceURI = getSourceURI(request);
218        URI targetURI = getTargetURI(request);
219        if (log.isDebugEnabled()) {
220            log.debug("Method: " + method + ", source: " + sourceURI + ", target: " + targetURI);
221        }
222
223        boolean allow;
224        if (isSafeMethod(method)) {
225            // safe method according to RFC 7231 4.2.1
226            log.debug("Safe method: allow");
227            allow = true;
228        } else if (sourceAndTargetMatch(sourceURI, targetURI)) {
229            // source and target match, or not provided
230            log.debug("Source and target match: allow");
231            if (targetURI == null) {
232                // misconfigured server or proxy headers
233                log.error("Cannot determine target URL for CSRF check");
234            }
235            allow = true;
236        } else if (corsConfig == null) {
237            // source not known by CORS config: be safe and disallow
238            log.debug("URL not covered by CORS config: disallow cross-site request");
239            allow = false;
240        } else if (!corsConfig.isAllowedOrigin(originFromURI(sourceURI))) {
241            // not in allowed CORS origins
242            log.debug("Origin not allowed by CORS config: disallow cross-site request");
243            allow = false;
244        } else if (!corsConfig.isSupportedMethod(method)) {
245            // not in allowed CORS methods
246            log.debug("Method not allowed by CORS config: disallow cross-site request");
247            allow = false;
248        } else {
249            log.debug("Origin and method allowed by CORS config: allow cross-site request");
250            allow = true;
251        }
252
253        if (allow) {
254            if (corsFilter == null) {
255                chain.doFilter(request, response);
256            } else {
257                request = maybeIgnoreWhitelistedOrigin(request);
258                corsFilter.doFilter(request, response, chain);
259            }
260            return;
261        }
262
263        // disallowed cross-site request
264        String message = "CSRF check failure";
265        log.warn(message + ": source: " + sourceURI + " does not match target: " + targetURI
266                + " and not allowed by CORS config");
267        response.sendError(HttpServletResponse.SC_FORBIDDEN, message);
268    }
269
270    /**
271     * Check safe method according to RFC 7231 4.2.1.
272     */
273    protected boolean isSafeMethod(String method) {
274        return SAFE_METHODS.contains(method);
275    }
276
277    /**
278     * Manages the CSRF token.
279     * <p>
280     * This method may return a response with token fetch information or with an error if needed, in which case it will
281     * return {@code true}.
282     *
283     * @return {@code true} if the caller doesn't need to do more work (a response has been sent)
284     * @since 10.3
285     */
286    protected boolean manageCSRFToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
287        if (!csrfTokenEnabled) {
288            log.debug("No CSRF token check configured");
289            return false; // no check to do
290        }
291
292        String method = request.getMethod();
293        String path = request.getServletPath();
294        if (path == null) {
295            path = "";
296        }
297        String pathInfo = request.getPathInfo();
298        if (pathInfo != null) {
299            path += pathInfo;
300        }
301        String requestToken = request.getHeader(CSRF_TOKEN_HEADER);
302
303        // token fetch request
304        if (GET.equals(method) && path.isEmpty() && CSRF_TOKEN_FETCH.equals(requestToken)) {
305            HttpSession session = request.getSession(); // create if needed
306            String token = (String) session.getAttribute(CSRF_TOKEN_ATTRIBUTE);
307            if (token == null) {
308                token = generateNewToken();
309                session.setAttribute(CSRF_TOKEN_ATTRIBUTE, token);
310            }
311            log.debug("Returning CSRF token fetch");
312            response.setHeader(CSRF_TOKEN_HEADER, token);
313            response.setStatus(SC_OK);
314            return true;
315
316        }
317
318        // do we need to check the token?
319        if (isSafeMethod(method)) {
320            log.debug("No CSRF token check on safe method");
321            return false;
322        }
323
324        // is the endpoint specially configured to skip the token check?
325        if (csrfTokenSkipPaths.contains(path)) {
326            log.debug("No CSRF token check on configured endpoint");
327            return false;
328        }
329
330        // check the token
331        HttpSession session = request.getSession(false);
332        String token;
333        if (session == null || (token = (String) session.getAttribute(CSRF_TOKEN_ATTRIBUTE)) == null) {
334            log.debug("Error, no session or no CSRF token in session");
335            String message = "CSRF check failure";
336            log.warn(message + ": invalid token");
337            response.setHeader(CSRF_TOKEN_HEADER, CSRF_TOKEN_INVALID);
338            response.sendError(SC_FORBIDDEN, message);
339            return true;
340        }
341        if (StringUtils.isEmpty(requestToken)) {
342            // allow request parameter to contain the token too
343            requestToken = request.getParameter(CSRF_TOKEN_PARAM);
344        }
345        if (!token.equals(requestToken)) {
346            log.debug("Error, CSRF token does not match");
347            String message = "CSRF check failure";
348            log.warn(message + ": invalid token");
349            response.setHeader(CSRF_TOKEN_HEADER, CSRF_TOKEN_INVALID);
350            response.sendError(SC_FORBIDDEN, message);
351            return true;
352        }
353
354        // token is ok, proceed
355        log.debug("CSRF token matches");
356        return false;
357    }
358
359    protected String generateNewToken() {
360        return RandomStringUtils.random(40, 0, 0, true, true, null, RANDOM);
361    }
362
363    /**
364     * Gets the source URI: the URI of the page from which the request is actually coming.
365     * <p>
366     * {@code null} is returned is there is no header.
367     * <p>
368     * {@link #PRIVACY_SENSITIVE} is returned is there is a null origin (RFC 6454 7.3, "privacy-sensitive" context)
369     * unless configured to be ignored.
370     */
371    public URI getSourceURI(HttpServletRequest request) {
372        String source = request.getHeader(ORIGIN);
373        if (isBlank(source)) {
374            source = request.getHeader(REFERER);
375        }
376        if (isBlank(source)) {
377            return null;
378        }
379        source = source.trim();
380        if (ORIGIN_NULL.equals(source)) {
381            return allowNullOrigin ? null : PRIVACY_SENSITIVE;
382        }
383        if (source.contains(" ")) {
384            // RFC 6454 7.1 origin-list
385            // keep only the first origin to simplify the logic (nobody sends two origins anyway)
386            source = source.substring(0, source.indexOf(' '));
387        }
388        try {
389            return new URI(source); // NOSONAR (URI is not opened as a stream)
390        } catch (URISyntaxException e) {
391            return null;
392        }
393    }
394
395    /** Gets the target URI: the URI to which the browser is connecting. */
396    public URI getTargetURI(HttpServletRequest request) {
397        String baseURL = VirtualHostHelper.getServerURL(request, false);
398        if (baseURL == null) {
399            return null;
400        }
401        try {
402            return new URI(baseURL); // NOSONAR (URI is not opened as a stream)
403        } catch (URISyntaxException e) {
404            return null;
405        }
406    }
407
408    public boolean sourceAndTargetMatch(URI sourceURI, URI targetURI) {
409        if (sourceURI == null || targetURI == null) {
410            return true;
411        }
412        if (isWhitelistedScheme(sourceURI)) {
413            return true;
414        }
415        return Objects.equals(sourceURI.getScheme(), targetURI.getScheme()) //
416                && Objects.equals(sourceURI.getHost(), targetURI.getHost()) //
417                && sourceURI.getPort() == targetURI.getPort();
418    }
419
420    /**
421     * Gets an Origin from a URI. Strips the path and query (which may be present in Referer headers).
422     */
423    protected Origin originFromURI(URI uri) {
424        // remove path, query and fragment
425        try {
426            uri = new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), null, null, null);
427        } catch (URISyntaxException e) {
428            // keep passed-in URI
429        }
430        return new Origin(uri.toString());
431    }
432
433    protected HttpServletRequest maybeIgnoreWhitelistedOrigin(HttpServletRequest request) {
434        String origin = request.getHeader(ORIGIN);
435        if (origin == null) {
436            return request;
437        }
438        URI uri;
439        try {
440            uri = new URI(origin); // NOSONAR (URI is not opened as a stream)
441        } catch (URISyntaxException e) {
442            return request;
443        }
444        if (!isWhitelistedScheme(uri)) {
445            return request;
446        }
447        // wrap request to pretend that the Origin is absent
448        return new IgnoredOriginRequestWrapper(request);
449    }
450
451    protected boolean isWhitelistedScheme(URI uri) {
452        return SCHEMES_ALLOWED.contains(uri.getScheme());
453    }
454
455    /**
456     * Wrapper for the request to hide the Origin header.
457     *
458     * @since 10.2
459     */
460    public static class IgnoredOriginRequestWrapper extends HttpServletRequestWrapper {
461
462        public IgnoredOriginRequestWrapper(HttpServletRequest request) {
463            super(request);
464        }
465
466        @Override
467        public String getHeader(String name) {
468            if (ORIGIN.equalsIgnoreCase(name)) {
469                return null;
470            }
471            return super.getHeader(name);
472        }
473    }
474
475}