Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import org.apache.brooklyn.location.localhost.LocalhostMachineProvisioningLocation;
import org.apache.brooklyn.rest.BrooklynWebConfig;
import org.apache.brooklyn.rest.RestApiSetup;
import org.apache.brooklyn.rest.filter.CsrfTokenFilter;
import org.apache.brooklyn.rest.filter.EntitlementContextFilter;
import org.apache.brooklyn.rest.filter.HaHotCheckResourceFilter;
import org.apache.brooklyn.rest.filter.LoggingFilter;
Expand Down Expand Up @@ -460,7 +461,8 @@ private WebAppContext deployRestApi(WebAppContext context) {
new RequestTaggingRsFilter(),
new NoCacheFilter(),
new HaHotCheckResourceFilter(),
new EntitlementContextFilter());
new EntitlementContextFilter(),
new CsrfTokenFilter());
RestApiSetup.installServletFilters(context,
RequestTaggingFilter.class,
LoggingFilter.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ public void shutdown(

@GET
@Path("/up/extended")
@ApiOperation(value = "Returns extended server-up information, a map including up (/up), shuttingDown (/shuttingDown), healthy (/healthy), and ha (/ha/states) (qv)")
@ApiOperation(value = "Returns extended server-up information, a map including up (/up), shuttingDown (/shuttingDown), healthy (/healthy), and ha (/ha/states) (qv)"
+ "; also forces a session, so a useful general-purpose call for a UI client to do when starting")
public Map<String,Object> getUpExtended();

@GET
Expand Down Expand Up @@ -198,7 +199,8 @@ public Response exportPersistenceData(

@GET
@Path("/user")
@ApiOperation(value = "Return user information for this Brooklyn instance",
@ApiOperation(value = "Return user information for this Brooklyn instance"
+ "; also forces a session, so a useful general-purpose call for a UI client to do when starting",
response = String.class,
responseContainer = "List")
public String getUser();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package org.apache.brooklyn.rest.filter;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;

import javax.annotation.Priority;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.NewCookie;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;

import org.apache.brooklyn.core.mgmt.entitlement.Entitlements;
import org.apache.brooklyn.rest.api.ServerApi;
import org.apache.brooklyn.rest.domain.ApiError;
import org.apache.brooklyn.util.text.Identifiers;
import org.apache.brooklyn.util.text.Strings;
import org.apache.commons.collections.EnumerationUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Predicates;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;

/**
* Causes the server to return a "Csrf-Token" cookie in certain circumstances,
* and thereafter to require clients to send that token as an "X-Csrf-Token" header on certain requests.
* <p>
* To enable REST requests to work from the <code>br</code> CLI and regular <code>curl</code> without CSRF,
* this CSRF protection only applies when a session is in effect.
* Sessions are only established by some REST requests:
* {@link ServerApi#getUpExtended()} is a good choice (/v1/server/up/extended).
* It can also be forced by using the {@link #CSRF_TOKEN_REQUIRED_HEADER} header on any REST request.
* Browser-based UI clients should use one of the above methods early in operation.
* <p>
* The standard model is that the token is required for all <i>write</i> (ie non-GET) requests being made
* with a valid session cookie (ie the request is part of an ongoing session).
* In such cases, the client must send the X-Csrf-Token as a header.
* This prevents a third-party site from effecting a mutating cross-site request via the browser.
* <p>
* For transitional reasons, the default behaviour in the current implementation is to warn (not fail)
* if no token is supplied in the above case. This will likely be changed to enforce the standard model
* in a subsequent version, but it avoids breaking backwards compatibility for any existing session-based clients.
* <p>
* Clients can force different required behaviour (e.g. "fail") by including the
* {@link #CSRF_TOKEN_REQUIRED_HEADER} with one of the values in {@link CsrfTokenRequiredForRequests},
* viz. to require the header on ALL requests, or on NONE, instead of just on WRITE requests (the default).
* If such a mode is explicitly specified it is enforced (instead of just displaying the transitional warning),
* so while transitioning to enforce CSRF this header should be supplied.
* The Brooklyn UI does this.
* <p>
* This uses *two* names, <code>Csrf-Token</code> and <code>Xsrf-Token</code>.
* The former seems the usual convention, but Angular apps use the latter.
* This strategy should mean that Angular apps should get CSRF protection with no special configuration.
* <p>
* The cookies are sent by the client on every request, by virtue of being cookies,
* although this is not necessary (and rather wasteful). A client may optimise by deleting the cookies
* and caching the information in another form.
* The cookie names do not have any salt/uid, so in dev on localhost you may get conflicts, e.g. between
* multiple Brooklyn instances or other apps that use the same token names.
* (In contrast the session ID token, usually JSESSIONID, has a BROOKLYN<uid> field at runtime to prevent
* such collisions.)
* <p>
* Additional CSRF strategies might be worth considering, in addition to the one described above:
* <ul>
* <li> Compare "Referer / Origin" against "Host" / "X-Forwarded-Host" (or app knowing its location)
* (disallowing if different is probably a good idea)
* <li> Look for "X-Requested-With: XMLHttpRequest" on POST, esp if there is cookie and/or user agent is a browser
* (but angular drops this one)
* </ul>
* More info at: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet .
* (These have not been implemented because the cookie pattern is sufficient, and one of the strongest.)
*
*/
@Provider
@Priority(400)
public class CsrfTokenFilter implements ContainerRequestFilter, ContainerResponseFilter {

private static final Logger log = LoggerFactory.getLogger(CsrfTokenFilter.class);

private static final String CSRF_TOKEN_VALUE_BASE = "Csrf-Token";
public static final String CSRF_TOKEN_VALUE_COOKIE = CSRF_TOKEN_VALUE_BASE.toUpperCase();
public static final String CSRF_TOKEN_VALUE_HEADER = HEADER_OF_COOKIE(CSRF_TOKEN_VALUE_BASE);
// also send this so angular works as expected
public static final String CSRF_TOKEN_VALUE_BASE_ANGULAR_NAME = "Xsrf-Token";
public static final String CSRF_TOKEN_VALUE_COOKIE_ANGULAR_NAME = CSRF_TOKEN_VALUE_BASE_ANGULAR_NAME.toUpperCase();
public static final String CSRF_TOKEN_VALUE_HEADER_ANGULAR_NAME = HEADER_OF_COOKIE(CSRF_TOKEN_VALUE_BASE_ANGULAR_NAME);

public static final String CSRF_TOKEN_VALUE_ATTR = CsrfTokenFilter.class.getName()+"."+CSRF_TOKEN_VALUE_COOKIE;

public static String HEADER_OF_COOKIE(String cookieName) {
return "X-"+cookieName;
}

public static final String CSRF_TOKEN_REQUIRED_HEADER = "X-Csrf-Token-Required-For-Requests";
public static final String CSRF_TOKEN_REQUIRED_ATTR = CsrfTokenFilter.class.getName()+"."+CSRF_TOKEN_REQUIRED_HEADER;
public static enum CsrfTokenRequiredForRequests { ALL, WRITE, NONE, }
public static CsrfTokenRequiredForRequests DEFAULT_REQUIRED_FOR_REQUESTS = CsrfTokenRequiredForRequests.WRITE;

@Context
private HttpServletRequest request;

@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
// if header supplied, check it is valid
String requiredWhenS = request.getHeader(CSRF_TOKEN_REQUIRED_HEADER);
if (Strings.isNonBlank(requiredWhenS) && getRequiredForRequests(requiredWhenS, null)==null) {
fail(requestContext, ApiError.builder().errorCode(Response.Status.BAD_REQUEST)
.message(HEADER_OF_COOKIE(CSRF_TOKEN_REQUIRED_HEADER)+" header if supplied must be one of "
+ Arrays.asList(CsrfTokenRequiredForRequests.values()))
.build());
return;
}

if (!request.isRequestedSessionIdValid()) {
// no session; just return
return;
}

@SuppressWarnings("unchecked")
List<String> suppliedTokensDefault = (List<String>) EnumerationUtils.toList(request.getHeaders(CSRF_TOKEN_VALUE_HEADER));
@SuppressWarnings("unchecked")
List<String> suppliedTokensAngular = (List<String>) EnumerationUtils.toList(request.getHeaders(CSRF_TOKEN_VALUE_HEADER_ANGULAR_NAME));
List<String> suppliedTokens = Lists.newArrayList(suppliedTokensDefault);
suppliedTokens.addAll(suppliedTokensAngular);

Object requiredToken = request.getSession().getAttribute(CSRF_TOKEN_VALUE_ATTR);
CsrfTokenRequiredForRequests whenRequired = (CsrfTokenRequiredForRequests) request.getSession().getAttribute(CSRF_TOKEN_REQUIRED_ATTR);

boolean isRequired;

if (whenRequired==null) {
if (suppliedTokens.isEmpty()) {
log.warn("No CSRF token expected or supplied but a cookie-session is active: client should be updated"
+ " (in future CSRF tokens or instructions may be required for session-based clients)"
+ " - " + Entitlements.getEntitlementContext());
whenRequired = CsrfTokenRequiredForRequests.NONE;
} else {
// default
whenRequired = DEFAULT_REQUIRED_FOR_REQUESTS;
}
// remember it to suppress warnings subsequently
request.getSession().setAttribute(CSRF_TOKEN_REQUIRED_ATTR, whenRequired);
}

switch (whenRequired) {
case NONE:
isRequired = false;
break;
case WRITE:
isRequired = !org.eclipse.jetty.http.HttpMethod.GET.toString().equals(requestContext.getMethod());
break;
case ALL:
isRequired = true;
break;
default:
log.warn("Unexpected "+CSRF_TOKEN_REQUIRED_ATTR+" value "+whenRequired);
isRequired = true;
}

if (Iterables.any(suppliedTokens, Predicates.equalTo(requiredToken))) {
// matching token supplied, or not being used
return;
}

if (!isRequired) {
// csrf not required, but it doesn't match
if (requiredToken!=null) {
log.trace("CSRF optional token mismatch: client did not send valid token, but it isn't required so proceeding");
}
return;
}

if (suppliedTokens.isEmpty()) {
fail(requestContext, ApiError.builder().errorCode(Response.Status.UNAUTHORIZED)
.message(HEADER_OF_COOKIE(CSRF_TOKEN_VALUE_COOKIE)+" header is required, containing token previously returned from server in cookie")
.build());
} else {
fail(requestContext, ApiError.builder().errorCode(Response.Status.UNAUTHORIZED)
.message(HEADER_OF_COOKIE(CSRF_TOKEN_VALUE_COOKIE)+" header did not match expected CSRF token")
.build());
}
}

private void fail(ContainerRequestContext requestContext, ApiError apiError) {
requestContext.abortWith(apiError.asJsonResponse());
}

@Override
public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException {
HttpSession session = request.getSession(false);
String token = (String) (session==null ? null : session.getAttribute(CSRF_TOKEN_VALUE_ATTR));
String requiredWhenS = request.getHeader(CSRF_TOKEN_REQUIRED_HEADER);

if (session==null) {
if (Strings.isBlank(requiredWhenS)) {
// no session and no requirement specified, bail out
return;
}
// explicit requirement request forces a session
session = request.getSession();
}

CsrfTokenRequiredForRequests requiredWhen;
if (Strings.isNonBlank(requiredWhenS)) {
requiredWhen = getRequiredForRequests(requiredWhenS, DEFAULT_REQUIRED_FOR_REQUESTS);
request.getSession().setAttribute(CSRF_TOKEN_REQUIRED_ATTR, requiredWhen);
if (Strings.isNonBlank(token)) {
// already set csrf token, and the client got it
// (with the session token if they are in a session;
// or if client didn't get it it isn't in a session)
return;
}
} else {
if (Strings.isNonBlank(token)) {
// already set csrf token, and the client got it
// (with the session token if they are in a session;
// or if client didn't get it it isn't in a session)
return;
}
requiredWhen = (CsrfTokenRequiredForRequests) request.getSession().getAttribute(CSRF_TOKEN_REQUIRED_ATTR);
if (requiredWhen==null) {
requiredWhen = DEFAULT_REQUIRED_FOR_REQUESTS;
request.getSession().setAttribute(CSRF_TOKEN_REQUIRED_ATTR, requiredWhen);
}
}

if (requiredWhen==CsrfTokenRequiredForRequests.NONE) {
// not required; so don't set
return;
}

// create the token
token = Identifiers.makeRandomId(16);
request.getSession().setAttribute(CSRF_TOKEN_VALUE_ATTR, token);

addCookie(responseContext, CSRF_TOKEN_VALUE_COOKIE, token, "Clients should send this value in header "+CSRF_TOKEN_VALUE_HEADER+" for validation");
addCookie(responseContext, CSRF_TOKEN_VALUE_COOKIE_ANGULAR_NAME, token, "Compatibility cookie for "+CSRF_TOKEN_VALUE_COOKIE+" following AngularJS conventions");
}

protected NewCookie addCookie(ContainerResponseContext responseContext, String cookieName, String token, String comment) {
NewCookie cookie = new NewCookie(cookieName, token, "/", null, comment, -1, false);
responseContext.getHeaders().add("Set-Cookie", cookie);
return cookie;
}

protected CsrfTokenRequiredForRequests getRequiredForRequests(String requiredWhenS, CsrfTokenRequiredForRequests defaultMode) {
CsrfTokenRequiredForRequests result = null;
if (requiredWhenS!=null) {
result = CsrfTokenRequiredForRequests.valueOf(requiredWhenS.toUpperCase());
}
if (result!=null) return result;
return defaultMode;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
import javax.ws.rs.ext.ContextResolver;

import org.apache.brooklyn.api.entity.Entity;
import org.apache.brooklyn.api.entity.EntityLocal;
import org.apache.brooklyn.api.mgmt.ManagementContext;
import org.apache.brooklyn.core.config.render.RendererHints;
import org.apache.brooklyn.core.mgmt.internal.ManagementContextInternal;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
import org.apache.brooklyn.rest.util.WebResourceUtils;
import org.apache.brooklyn.util.core.flags.TypeCoercions;
import org.apache.brooklyn.util.core.task.Tasks;
import org.apache.brooklyn.util.core.task.ValueResolver;
import org.apache.brooklyn.util.text.Strings;
import org.apache.brooklyn.util.time.Duration;
import org.slf4j.Logger;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,35 @@
import org.apache.brooklyn.core.mgmt.entitlement.Entitlements;
import org.apache.brooklyn.core.mgmt.entitlement.WebEntitlementContext;
import org.apache.brooklyn.rest.api.LogoutApi;
import org.apache.brooklyn.rest.security.jaas.BrooklynLoginModule;
import org.apache.brooklyn.util.exceptions.Exceptions;

import com.google.common.net.HttpHeaders;

public class LogoutResource extends AbstractBrooklynRestResource implements LogoutApi {

private static final String BASIC_REALM_WEBCONSOLE = "Basic realm=\""+BrooklynLoginModule.DEFAULT_ROLE+"\"";

@Context HttpServletRequest req;
@Context UriInfo uri;

@Override
public Response logout() {
WebEntitlementContext ctx = (WebEntitlementContext) Entitlements.getEntitlementContext();

if (ctx==null) {
return Response.status(Status.BAD_REQUEST)
.entity("No user logged in")
.header(HttpHeaders.WWW_AUTHENTICATE, BASIC_REALM_WEBCONSOLE)
.build();
}

URI dest = uri.getBaseUriBuilder().path(LogoutApi.class).path(LogoutApi.class, "logoutUser").build(ctx.user());

// When execution gets here we don't know whether this is the first fetch of logout() or a subsequent one
// with a re-authenticated user. The only way to tell is compare if user names changed. So redirect to an URL
// which contains the user name.
return Response.status(Status.TEMPORARY_REDIRECT)
.header("Location", dest.toASCIIString())
.build();
return Response.temporaryRedirect(dest).build();
}

@Override
Expand All @@ -58,7 +70,7 @@ public Response logoutUser(String user) {
doLogout();

return Response.status(Status.UNAUTHORIZED)
.header("WWW-Authenticate", "Basic realm=\"webconsole\"")
.header(HttpHeaders.WWW_AUTHENTICATE, BASIC_REALM_WEBCONSOLE)
.build();
} else {
return Response.temporaryRedirect(uri.getAbsolutePathBuilder().replacePath("/").build()).build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
import org.apache.brooklyn.rest.filter.HaHotStateRequired;
import org.apache.brooklyn.rest.transform.SensorTransformer;
import org.apache.brooklyn.rest.util.WebResourceUtils;
import org.apache.brooklyn.util.core.task.ValueResolver;
import org.apache.brooklyn.util.text.Strings;
import org.apache.brooklyn.util.time.Duration;
import org.slf4j.Logger;
Expand Down
Loading