NIFI-12487 Added CSRF Protection to Registry (#8136)

- Added CSRF Token Repository based on existing implementation
- Updated Angular Request Interceptor to read cookie and send Request-Token Header
This commit is contained in:
David Handermann 2023-12-18 14:08:01 -06:00 committed by GitHub
parent 49bbc38b6b
commit 2794193608
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 353 additions and 3 deletions

View File

@ -16,6 +16,7 @@
*/
package org.apache.nifi.registry.web.security;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.nifi.registry.security.authorization.Authorizer;
import org.apache.nifi.registry.security.authorization.resource.ResourceType;
import org.apache.nifi.registry.security.identity.IdentityMapper;
@ -23,6 +24,11 @@ import org.apache.nifi.registry.service.AuthorizationService;
import org.apache.nifi.registry.web.security.authentication.AnonymousIdentityFilter;
import org.apache.nifi.registry.web.security.authentication.IdentityAuthenticationProvider;
import org.apache.nifi.registry.web.security.authentication.IdentityFilter;
import org.apache.nifi.registry.web.security.authentication.csrf.CsrfCookieFilter;
import org.apache.nifi.registry.web.security.authentication.csrf.CsrfCookieName;
import org.apache.nifi.registry.web.security.authentication.csrf.CsrfRequestMatcher;
import org.apache.nifi.registry.web.security.authentication.csrf.StandardCookieCsrfTokenRepository;
import org.apache.nifi.registry.web.security.authentication.csrf.StandardCsrfTokenRequestAttributeHandler;
import org.apache.nifi.registry.web.security.authentication.jwt.JwtIdentityProvider;
import org.apache.nifi.registry.web.security.authentication.x509.X509IdentityAuthenticationProvider;
import org.apache.nifi.registry.web.security.authentication.x509.X509IdentityProvider;
@ -32,6 +38,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@ -41,10 +48,15 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfException;
import java.io.IOException;
import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
@ -81,7 +93,12 @@ public class NiFiRegistrySecurityConfig {
// Add Resource Authorization after Spring Security but before Jersey Resources
.addFilterAfter(resourceAuthorizationFilter(), AuthorizationFilter.class)
.anonymous(anonymous -> anonymous.authenticationFilter(new AnonymousIdentityFilter()))
.csrf(AbstractHttpConfigurer::disable)
.csrf(csrf -> {
csrf.requireCsrfProtectionMatcher(new CsrfRequestMatcher());
csrf.csrfTokenRepository(new StandardCookieCsrfTokenRepository());
csrf.csrfTokenRequestHandler(new StandardCsrfTokenRequestAttributeHandler());
})
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
.logout(AbstractHttpConfigurer::disable)
.rememberMe(AbstractHttpConfigurer::disable)
.requestCache(AbstractHttpConfigurer::disable)
@ -106,7 +123,8 @@ public class NiFiRegistrySecurityConfig {
.anyRequest().fullyAuthenticated()
)
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(http401AuthenticationEntryPoint())
.authenticationEntryPoint(http401AuthenticationEntryPoint())
.accessDeniedHandler(new StandardAccessDeniedHandler())
)
.build();
}
@ -158,4 +176,26 @@ public class NiFiRegistrySecurityConfig {
}
};
}
private static class StandardAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(final HttpServletRequest request, final HttpServletResponse response, final AccessDeniedException accessDeniedException) throws IOException {
final String message;
final int status;
if (accessDeniedException instanceof CsrfException) {
status = HttpServletResponse.SC_FORBIDDEN;
message = "Access Denied: CSRF Header and Cookie not matched";
logger.info("Access Denied: CSRF Header [{}] not matched: {}", CsrfCookieName.REQUEST_TOKEN.getCookieName(), accessDeniedException.toString());
} else {
status = HttpServletResponse.SC_UNAUTHORIZED;
message = "Access Denied";
logger.debug(message, accessDeniedException);
}
response.setStatus(status);
response.setContentType("text/plain");
response.getWriter().println(message);
}
}
}

View File

@ -0,0 +1,42 @@
/*
* 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.nifi.registry.web.security.authentication.csrf;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* Cross-Site Request Forgery Cookie Filter based on Spring Security recommendations for Single-Page Applications
*/
public class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException {
// Get CSRF Token set from Request Attribute Handler
final CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
// Invoking CsrfToken.getToken() causes deferred tokens to be loaded and set as response headers
csrfToken.getToken();
filterChain.doFilter(request, response);
}
}

View File

@ -0,0 +1,35 @@
/*
* 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.nifi.registry.web.security.authentication.csrf;
/**
* Cross-Site Request Forgery Mitigation Cookie Names
*/
public enum CsrfCookieName {
/** Cross-Site Request Forgery mitigation token requires Strict Same Site handling */
REQUEST_TOKEN("__Secure-Request-Token");
private final String cookieName;
CsrfCookieName(final String cookieName) {
this.cookieName = cookieName;
}
public String getCookieName() {
return cookieName;
}
}

View File

@ -0,0 +1,45 @@
/*
* 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.nifi.registry.web.security.authentication.csrf;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.util.matcher.RequestMatcher;
import static org.springframework.web.util.WebUtils.getCookie;
/**
* Cross-Site Request Forgery Mitigation Request Matcher
*/
public class CsrfRequestMatcher implements RequestMatcher {
@Override
public boolean matches(final HttpServletRequest request) {
final boolean matches;
if (CsrfFilter.DEFAULT_CSRF_MATCHER.matches(request)) {
// Presence of Request Token Cookie requires invoking the CsrfFilter
final Cookie requestTokenCookie = getCookie(request, CsrfCookieName.REQUEST_TOKEN.getCookieName());
matches = requestTokenCookie != null;
} else {
matches = false;
}
return matches;
}
}

View File

@ -0,0 +1,116 @@
/*
* 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.nifi.registry.web.security.authentication.csrf;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.util.StringUtils;
import org.springframework.web.util.WebUtils;
import java.time.Duration;
import java.util.UUID;
/**
* Standard implementation of CSRF Token Repository using stateless Spring Security double-submit cookie strategy
*/
public class StandardCookieCsrfTokenRepository implements CsrfTokenRepository {
private static final String REQUEST_HEADER = "Request-Token";
private static final String REQUEST_PARAMETER = "requestToken";
private static final Duration MAX_AGE_SESSION = Duration.ofSeconds(-1);
private static final Duration MAX_AGE_REMOVE = Duration.ZERO;
private static final String EMPTY_VALUE = "";
private static final String SAME_SITE_POLICY_STRICT = "Strict";
private static final String ROOT_PATH = "/";
private static final boolean SECURE_ENABLED = true;
/**
* Generate CSRF Token or return current Token when present in HTTP Servlet Request Cookie header
*
* @param httpServletRequest HTTP Servlet Request
* @return CSRF Token
*/
@Override
public CsrfToken generateToken(final HttpServletRequest httpServletRequest) {
CsrfToken csrfToken = loadToken(httpServletRequest);
if (csrfToken == null) {
csrfToken = getCsrfToken(generateRandomToken());
}
return csrfToken;
}
/**
* Save CSRF Token in HTTP Servlet Response using defaults that allow JavaScript read for session cookies
*
* @param csrfToken CSRF Token to be saved or null indicated the token should be removed
* @param httpServletRequest HTTP Servlet Request
* @param httpServletResponse HTTP Servlet Response
*/
@Override
public void saveToken(final CsrfToken csrfToken, final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse) {
if (csrfToken == null) {
final ResponseCookie responseCookie = getResponseCookie(EMPTY_VALUE, MAX_AGE_REMOVE);
httpServletResponse.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());
} else {
final String token = csrfToken.getToken();
final ResponseCookie responseCookie = getResponseCookie(token, MAX_AGE_SESSION);
httpServletResponse.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());
}
}
/**
* Load CSRF Token from HTTP Servlet Request Cookie header
*
* @param httpServletRequest HTTP Servlet Request
* @return CSRF Token or null when Cookie header not found
*/
@Override
public CsrfToken loadToken(final HttpServletRequest httpServletRequest) {
final Cookie cookie = WebUtils.getCookie(httpServletRequest, CsrfCookieName.REQUEST_TOKEN.getCookieName());
final String token = cookie == null ? null : cookie.getValue();
return StringUtils.hasLength(token) ? getCsrfToken(token) : null;
}
private CsrfToken getCsrfToken(final String token) {
return new DefaultCsrfToken(REQUEST_HEADER, REQUEST_PARAMETER, token);
}
private String generateRandomToken() {
return UUID.randomUUID().toString();
}
private ResponseCookie getResponseCookie(final String cookieValue, final Duration maxAge) {
return ResponseCookie.from(CsrfCookieName.REQUEST_TOKEN.getCookieName(), cookieValue)
.sameSite(SAME_SITE_POLICY_STRICT)
.secure(SECURE_ENABLED)
.path(ROOT_PATH)
.maxAge(maxAge)
.build();
}
}

View File

@ -0,0 +1,66 @@
/*
* 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.nifi.registry.web.security.authentication.csrf;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;
import java.util.function.Supplier;
/**
* Cross-Site Request Forgery Mitigation Token Handler implementation supporting resolution using Request Header
*/
public class StandardCsrfTokenRequestAttributeHandler extends CsrfTokenRequestAttributeHandler {
private final XorCsrfTokenRequestAttributeHandler handler = new XorCsrfTokenRequestAttributeHandler();
/**
* Handle Request using standard Spring Security implementation
*
* @param request HTTP Servlet Request being handled
* @param response HTTP Servlet Response being handled
* @param csrfTokenSupplier Supplier for CSRF Token
*/
@Override
public void handle(final HttpServletRequest request, final HttpServletResponse response, final Supplier<CsrfToken> csrfTokenSupplier) {
this.handler.handle(request, response, csrfTokenSupplier);
}
/**
* Resolve CSRF Token Value from HTTP Request Header
*
* @param request HTTP Servlet Request being processed
* @param csrfToken CSRF Token created from a CSRF Token Repository
* @return Token Value from Request Header or null when not found
*/
@Override
public String resolveCsrfTokenValue(final HttpServletRequest request, final CsrfToken csrfToken) {
final String headerTokenValue = request.getHeader(csrfToken.getHeaderName());
final String resolvedToken;
if (StringUtils.hasText(headerTokenValue)) {
resolvedToken = super.resolveCsrfTokenValue(request, csrfToken);
} else {
resolvedToken = null;
}
return resolvedToken;
}
}

View File

@ -25,12 +25,13 @@ import NfStorage from 'services/nf-storage.service';
*/
function NfRegistryTokenInterceptor(nfStorage) {
this.nfStorage = nfStorage;
this.requestTokenPattern = /Request-Token=([^;]+)/;
}
NfRegistryTokenInterceptor.prototype = {
constructor: NfRegistryTokenInterceptor,
/**
* Injects the authorization token into the request headers.
* Injects the authorization token and request token cookie value into the request headers.
*
* @param request angular HttpRequest.
* @param next angular HttpHandler.
@ -41,6 +42,11 @@ NfRegistryTokenInterceptor.prototype = {
if (token) {
request = request.clone({headers: request.headers.set('Authorization', 'Bearer ' + token)});
}
var requestTokenMatcher = this.requestTokenPattern.exec(document.cookie);
if (requestTokenMatcher) {
var requestToken = requestTokenMatcher[1];
request = request.clone({headers: request.headers.set('Request-Token', requestToken)});
}
return next.handle(request);
}
};