/* * #%L * Wisdom-Framework * %% * Copyright (C) 2013 - 2015 Wisdom Framework * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * #L% */ package org.wisdom.framework.csrf; import org.apache.felix.ipojo.annotations.Requires; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.wisdom.api.annotations.Service; import org.wisdom.api.configuration.Configuration; import org.wisdom.api.cookies.Cookie; import org.wisdom.api.crypto.Crypto; import org.wisdom.api.http.*; import org.wisdom.framework.csrf.api.CSRFErrorHandler; import org.wisdom.framework.csrf.api.CSRFService; import java.util.List; /** * Implementation of the {@link org.wisdom.framework.csrf.api.CSRFService}. This implementation requires a * configuration: * <code><pre> * csrf { * token { * name = the name of the token, it's the (form) field or parameter containing the token * sign = whether or not tokens need to be signed, enabled by default * } * cookie { * name = the optional name of the cookie, if not set it uses the session * domain = the optional domain * path = the path, / by default * secure = is the cookie secure or not, default to true * } * } * </pre></code> */ @Service public class CSRFServiceImpl implements CSRFService { private static final Logger LOGGER = LoggerFactory.getLogger(CSRFService.class.getName()); public static final String CSRF_TOKEN_HEADER = "X-XSRF-TOKEN"; public static final String NO_CHECK_HEADER_VALUE = "no-check"; public static final String AJAX_HEADER = "X-Requested-With"; /** * The crypto service. Public for testing purpose. */ @Requires public Crypto crypto; /** * The CSRF configuration. Public for testing purpose. */ @Requires(filter = "(configuration.path=csrf)") public Configuration configuration; /** * The CSRF Error Handler. Public for testing purpose. */ @Requires(optional = true, defaultimplementation = DefaultCSRFErrorHandler.class) public CSRFErrorHandler handler; /** * Extracts the token from the request. This implementation checks in the request data, then in the CORS cookie * if any, and finally in the session cookie. If the token is signed, it resigns it to avoid the BREACH * vulnerability. * * @param context the context * @return the extract token, {@code null} if no token. */ @Override public String extractTokenFromRequest(Context context) { // First check the tags, this is where tokens are added if it's added to the current request // In that case, the request scope contains the token String token = (String) context.request().data().get(TOKEN_KEY); if (token == null && getCookieName() != null) { // Search in a cookie Cookie cookie = context.cookie(getCookieName()); if (cookie != null) { token = cookie.value(); } } if (token == null) { // Check in the session token = context.session().get(getTokenName()); } if (token != null && isSignedToken()) { // Extract the signed token, and then resign it. This makes the token random per request, preventing // the BREACH vulnerability return crypto.signToken(crypto.extractSignedToken(token)); } else { return token; } } /** * Checks whether or not the request is valid (not by passed, valid token). * * @param context the context * @return {@code true} if the request if valid, {@code false} otherwise. */ @Override public boolean isValidRequest(Context context) { // Check if we are executing an unsafe method if (!isUnsafe(context)) { return true; } if (checkCsrfBypass(context)) { LOGGER.debug("Bypassing CSRF check for {} {}", context.route().getHttpMethod(), context.route().getUrl()); return true; } else { String tokenFromRequest = extractTokenFromRequest(context); if (tokenFromRequest == null) { LOGGER.error("CSRF Check failed because there is no token in the incoming request headers"); return false; } String tokenFromContent = extractTokenFromContent(context); if (tokenFromContent == null) { LOGGER.error("CSRF Check failed because we are unable to find a token in the incoming request query " + "string or body"); return false; } if (compareTokens(tokenFromRequest, tokenFromContent)) { return true; } else { LOGGER.error("CSRF Check failed because the given token is invalid"); return false; } } } private boolean isUnsafe(Context context) { return context.route().getHttpMethod() == HttpMethod.POST && UNSAFE_CONTENT_TYPES.contains(context.request() .contentType()); } /** * Methods adding the CORS token to the given result. The effect depends on the configuration. * * @param context the context * @param newToken the new token * @param result the result that is enhanced * @return the updated result */ @Override public Result addTokenToResult(Context context, String newToken, Result result) { if (isCached(context)) { LOGGER.debug("Not adding token to a cached result"); return result; } LOGGER.debug("Adding token to result"); if (getCookieName() != null) { Cookie cookie = context.cookie(getCookieName()); if (cookie != null) { return result.with(Cookie.builder(cookie).setValue(newToken).build()); } else { // Create a new cookie return result.with(Cookie.cookie(getCookieName(), newToken) .setSecure(isSecureCookie()) .setPath(getCookiePath()) .setDomain(getCookieDomain()) .setMaxAge(3600) .build()); } } else { // Session context.session().put(getTokenName(), newToken); return result; } } private boolean isCached(Context context) { String cacheControl = context.request().getHeader(HeaderNames.CACHE_CONTROL); return cacheControl != null && !cacheControl.contains(HeaderNames.NOCACHE_VALUE); } private boolean checkCsrfBypass(Context context) { // Check whether the CSRF Header has the no-check value // Since injecting arbitrary header values is not possible with a CSRF attack, the presence of this header // indicates that this is not a CSRF attack if (context.header(CSRF_TOKEN_HEADER) != null && NO_CHECK_HEADER_VALUE.equalsIgnoreCase(context.header(CSRF_TOKEN_HEADER))) { return true; } // Check that 'X-Requested-With' header is defined // AJAX requests are not CSRF attacks either because they are restricted to same origin policy return context.header(AJAX_HEADER) != null; } /** * Compares to token. * * @param a the first token * @param b the second token * @return {@code true} if the token are equal, {@code false} otherwise */ @Override public boolean compareTokens(String a, String b) { if (isSignedToken()) { return crypto.compareSignedTokens(a, b); } else { return crypto.constantTimeEquals(a, b); } } /** * Extracts the token from the content of the request. This implementation checks inside the headers, and inside * form body. * * @param context the context * @return the extracted token, {@code null} if not found. */ @Override public String extractTokenFromContent(Context context) { // check query String String token = context.request().parameter(getTokenName()); if (token == null) { // Check a specified header token = context.header(CSRF_TOKEN_HEADER); } // If still not found, check in body if (token == null) { if (context.request().contentType().startsWith(MimeTypes.FORM) || context.request().contentType().startsWith(MimeTypes.MULTIPART)) { List<String> list = context.form().get(getTokenName()); if (list != null && !list.isEmpty()) { return list.get(0); } } } return token; } /** * Clears the token from the request * * @param context the context * @param msg the error message * @return the result */ @Override public Result clearTokenIfInvalid(Context context, String msg) { Result error = handler.onError(context, msg); final String cookieName = getCookieName(); if (cookieName != null) { Cookie cookie = context.cookie(cookieName); if (cookie != null) { return error.without(cookieName); } } else { context.session().remove(getTokenName()); } return error; } private boolean isSignedToken() { return configuration.getBooleanWithDefault("token.sign", true); } private boolean isSecureCookie() { return configuration.getBooleanWithDefault("cookie.secure", true); } public String getTokenName() { return configuration.getWithDefault("token.name", "csrfToken"); } @Override public String getCurrentToken(Context context) { return (String) context.request().data().get(TOKEN_KEY); } private String getCookiePath() { return configuration.getWithDefault("cookie.path", "/"); } private String getCookieDomain() { return configuration.getWithDefault("cookie.domain", null); } @Override public String generateToken(Context context) { String newToken; if (isSignedToken()) { newToken = crypto.generateSignedToken(); } else { newToken = crypto.generateToken(); } // Add the new token to the request scope context.request().data().put(TOKEN_KEY, newToken); return newToken; } @Override public boolean eligibleForCSRF(Context context) { // If the request isn't accepting HTML, then it won't be rendering a form, so there's no point in generating a // CSRF token for it. final HttpMethod method = context.route().getHttpMethod(); return // NO POST here, because, POST would mean another request has been done beforehand to retrieve the // first form. ((method == HttpMethod.GET) || method == HttpMethod.HEAD) && (context.request().accepts(MimeTypes.HTML) || context.request().accepts("application/xml+xhtml")); } private String getCookieName() { return configuration.get("cookie.name"); } }