From 27941936089ce6ef85ac1ba3a1bb0da536965e53 Mon Sep 17 00:00:00 2001 From: David Handermann Date: Mon, 18 Dec 2023 14:08:01 -0600 Subject: [PATCH] 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 --- .../security/NiFiRegistrySecurityConfig.java | 44 ++++++- .../authentication/csrf/CsrfCookieFilter.java | 42 +++++++ .../authentication/csrf/CsrfCookieName.java | 35 ++++++ .../csrf/CsrfRequestMatcher.java | 45 +++++++ .../StandardCookieCsrfTokenRepository.java | 116 ++++++++++++++++++ ...ndardCsrfTokenRequestAttributeHandler.java | 66 ++++++++++ .../services/nf-registry.token.interceptor.js | 8 +- 7 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfCookieFilter.java create mode 100644 nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfCookieName.java create mode 100644 nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfRequestMatcher.java create mode 100644 nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/StandardCookieCsrfTokenRepository.java create mode 100644 nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/StandardCsrfTokenRequestAttributeHandler.java diff --git a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/NiFiRegistrySecurityConfig.java b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/NiFiRegistrySecurityConfig.java index d52127a03e..0c60fd7ba9 100644 --- a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/NiFiRegistrySecurityConfig.java +++ b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/NiFiRegistrySecurityConfig.java @@ -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); + } + } } diff --git a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfCookieFilter.java b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfCookieFilter.java new file mode 100644 index 0000000000..a58d21baec --- /dev/null +++ b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfCookieFilter.java @@ -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); + } +} diff --git a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfCookieName.java b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfCookieName.java new file mode 100644 index 0000000000..c5a8d2386b --- /dev/null +++ b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfCookieName.java @@ -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; + } +} diff --git a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfRequestMatcher.java b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfRequestMatcher.java new file mode 100644 index 0000000000..f5048f4233 --- /dev/null +++ b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/CsrfRequestMatcher.java @@ -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; + } +} diff --git a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/StandardCookieCsrfTokenRepository.java b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/StandardCookieCsrfTokenRepository.java new file mode 100644 index 0000000000..ac333a5cdf --- /dev/null +++ b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/StandardCookieCsrfTokenRepository.java @@ -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(); + } +} diff --git a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/StandardCsrfTokenRequestAttributeHandler.java b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/StandardCsrfTokenRequestAttributeHandler.java new file mode 100644 index 0000000000..3eab88c976 --- /dev/null +++ b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/csrf/StandardCsrfTokenRequestAttributeHandler.java @@ -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 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; + } +} diff --git a/nifi-registry/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.token.interceptor.js b/nifi-registry/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.token.interceptor.js index 9511203325..f0c37342a7 100644 --- a/nifi-registry/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.token.interceptor.js +++ b/nifi-registry/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.token.interceptor.js @@ -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); } };