This is an automated email from the ASF dual-hosted git repository.
pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 119d41f7796 NIFI-16510 Added evaluation Filter for Sec-Fetch-Site
Header (#11855)
119d41f7796 is described below
commit 119d41f779668477d40cd816c84b99c1504c8c4e
Author: David Handermann <[email protected]>
AuthorDate: Tue Oct 27 11:05:06 2026 -0500
NIFI-16510 Added evaluation Filter for Sec-Fetch-Site Header (#11855)
---
.../src/main/asciidoc/administration-guide.adoc | 13 ++
.../configuration/WebSecurityConfiguration.java | 2 +
.../web/security/csrf/FetchSiteRequestFilter.java | 111 +++++++++++++
.../security/csrf/FetchSiteRequestFilterTest.java | 183 +++++++++++++++++++++
4 files changed, 309 insertions(+)
diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc
b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index 6f0401ed98b..8bde48a3c6f 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -803,6 +803,19 @@ The CSRF security filter expects the following HTTP
request header on non-idempo
- Header Name: `Request-Token`
- Value: UUID matching the `__Secure-Request-Token` cookie header
+NiFi also evaluates the `Sec-Fetch-Site` request header that web browsers send
as described in the
+link:https://www.w3.org/TR/fetch-metadata/[Fetch Metadata Request Headers]
specification. The header indicates the
+relationship between the site that initiated a request and the application
server. NiFi rejects requests with HTTP 403
+Forbidden for methods other than `GET`, `HEAD`, `OPTIONS`, or `TRACE` when the
`Sec-Fetch-Site` header meets either of
+the following conditions:
+
+- Header value is `cross-site`
+- Header contains more than one value
+
+Requests with a `Sec-Fetch-Site` header value of `same-origin`, `same-site`,
or `none` continue to standard CSRF
+filtering. Requests without the `Sec-Fetch-Site` header are not affected.
Cluster nodes do not add the header to
+requests that nodes initiate, and replicated requests include the header value
from the original request.
+
[[multi-tenant-authorization]]
== Multi-Tenant Authorization
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/WebSecurityConfiguration.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/WebSecurityConfiguration.java
index 7e7309b9584..44d0db36e3b 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/WebSecurityConfiguration.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/WebSecurityConfiguration.java
@@ -23,6 +23,7 @@ import
org.apache.nifi.web.security.StandardAuthenticationEntryPoint;
import
org.apache.nifi.web.security.anonymous.NiFiAnonymousAuthenticationFilter;
import org.apache.nifi.web.security.csrf.CsrfCookieFilter;
import org.apache.nifi.web.security.csrf.CsrfCookieRequestMatcher;
+import org.apache.nifi.web.security.csrf.FetchSiteRequestFilter;
import org.apache.nifi.web.security.csrf.SkipReplicatedCsrfFilter;
import org.apache.nifi.web.security.csrf.StandardCookieCsrfTokenRepository;
import
org.apache.nifi.web.security.csrf.StandardCsrfTokenRequestAttributeHandler;
@@ -136,6 +137,7 @@ public class WebSecurityConfiguration {
.anyRequest().authenticated()
)
.addFilterBefore(new SkipReplicatedCsrfFilter(),
CsrfFilter.class)
+ .addFilterBefore(new FetchSiteRequestFilter(),
CsrfFilter.class)
.addFilterAfter(new CsrfCookieFilter(),
BasicAuthenticationFilter.class)
.csrf(csrf -> csrf
.csrfTokenRepository(
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/csrf/FetchSiteRequestFilter.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/csrf/FetchSiteRequestFilter.java
new file mode 100644
index 00000000000..5b967ebdd07
--- /dev/null
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/csrf/FetchSiteRequestFilter.java
@@ -0,0 +1,111 @@
+/*
+ * 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.web.security.csrf;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.nifi.web.security.saml2.SamlUrlPath;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.web.csrf.CsrfFilter;
+import
org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
+import org.springframework.security.web.util.matcher.AndRequestMatcher;
+import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
+import org.springframework.security.web.util.matcher.OrRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+
+/**
+ * Fetch Site Request Filter rejects state-changing requests that browsers
identify as cross-site using the Sec-Fetch-Site header
+ */
+public class FetchSiteRequestFilter extends OncePerRequestFilter {
+ protected static final String FETCH_SITE_HEADER = "Sec-Fetch-Site";
+
+ private static final String CROSS_SITE = "cross-site";
+
+ private static final String HEADER_VALUE_SEPARATOR = ",";
+
+ /** SAML 2.0 HTTP-POST binding delivers Asserting Party messages to
consumer paths using cross-site form submissions */
+ private static final RequestMatcher SAML_CONSUMER_REQUEST_MATCHER = new
OrRequestMatcher(
+
PathPatternRequestMatcher.withDefaults().matcher(SamlUrlPath.LOGIN_RESPONSE.getPath()),
+
PathPatternRequestMatcher.withDefaults().matcher(SamlUrlPath.SINGLE_LOGOUT_RESPONSE.getPath())
+ );
+
+ /** State-changing requests other than SAML 2.0 consumer requests will be
evaluated */
+ private static final RequestMatcher REQUEST_MATCHER = new
AndRequestMatcher(
+ CsrfFilter.DEFAULT_CSRF_MATCHER,
+ new NegatedRequestMatcher(SAML_CONSUMER_REQUEST_MATCHER)
+ );
+
+ private static final Logger logger =
LoggerFactory.getLogger(FetchSiteRequestFilter.class);
+
+ /**
+ * Skip filtering for requests that do not change state and for SAML 2.0
consumer requests
+ *
+ * @param request HTTP Servlet Request
+ * @return Skip filtering status
+ */
+ @Override
+ protected boolean shouldNotFilter(final HttpServletRequest request) {
+ return !REQUEST_MATCHER.matches(request);
+ }
+
+ /**
+ * Reject requests containing multiple Sec-Fetch-Site header values or a
cross-site header value
+ *
+ * @param request HTTP Servlet Request
+ * @param response HTTP Servlet Response
+ * @param filterChain Filter Chain
+ * @throws ServletException Thrown on FilterChain.doFilter()
+ * @throws IOException Thrown on FilterChain.doFilter() or
HttpServletResponse.sendError()
+ */
+ @Override
+ protected void doFilterInternal(final HttpServletRequest request, final
HttpServletResponse response, final FilterChain filterChain) throws
ServletException, IOException {
+ final List<String> fetchSiteValues = getFetchSiteValues(request);
+
+ if (fetchSiteValues.size() > 1) {
+ logger.debug("Rejected {} {} with multiple {} header values",
request.getMethod(), request.getRequestURI(), FETCH_SITE_HEADER);
+ response.sendError(HttpServletResponse.SC_FORBIDDEN);
+ } else if (fetchSiteValues.size() == 1 &&
CROSS_SITE.equalsIgnoreCase(fetchSiteValues.getFirst())) {
+ logger.debug("Rejected {} {} with {} header value [{}]",
request.getMethod(), request.getRequestURI(), FETCH_SITE_HEADER, CROSS_SITE);
+ response.sendError(HttpServletResponse.SC_FORBIDDEN);
+ } else {
+ filterChain.doFilter(request, response);
+ }
+ }
+
+ private List<String> getFetchSiteValues(final HttpServletRequest request) {
+ final List<String> fetchSiteValues = new ArrayList<>();
+
+ final Enumeration<String> headers =
request.getHeaders(FETCH_SITE_HEADER);
+ while (headers.hasMoreElements()) {
+ final String header = headers.nextElement();
+ Collections.addAll(fetchSiteValues,
StringUtils.tokenizeToStringArray(header, HEADER_VALUE_SEPARATOR));
+ }
+
+ return fetchSiteValues;
+ }
+}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/csrf/FetchSiteRequestFilterTest.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/csrf/FetchSiteRequestFilterTest.java
new file mode 100644
index 00000000000..9a97aa73e10
--- /dev/null
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/csrf/FetchSiteRequestFilterTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.web.security.csrf;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.nifi.web.security.saml2.SamlUrlPath;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpMethod;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+@ExtendWith(MockitoExtension.class)
+class FetchSiteRequestFilterTest {
+ private static final String REQUEST_URI =
"/process-groups/root/processors";
+
+ private static final String CROSS_SITE = "cross-site";
+
+ private static final String SAME_ORIGIN = "same-origin";
+
+ private static final String SAME_SITE = "same-site";
+
+ private static final String NONE = "none";
+
+ MockHttpServletRequest httpServletRequest;
+
+ MockHttpServletResponse httpServletResponse;
+
+ @Mock
+ FilterChain filterChain;
+
+ FetchSiteRequestFilter filter;
+
+ @BeforeEach
+ void setFilter() {
+ filter = new FetchSiteRequestFilter();
+ httpServletResponse = new MockHttpServletResponse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"POST", "PUT", "PATCH", "DELETE"})
+ void testDoFilterCrossSiteRejected(final String method) throws
ServletException, IOException {
+ setRequest(method, CROSS_SITE);
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertRejected();
+ }
+
+ @Test
+ void testDoFilterCrossSiteUpperCaseRejected() throws ServletException,
IOException {
+ setRequest(HttpMethod.POST.name(),
CROSS_SITE.toUpperCase(Locale.ROOT));
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertRejected();
+ }
+
+ @Test
+ void testDoFilterReplicatedCrossSiteRejected() throws ServletException,
IOException {
+ setRequest(HttpMethod.POST.name(), CROSS_SITE);
+
httpServletRequest.addHeader(SkipReplicatedCsrfFilter.REPLICATED_REQUEST_HEADER,
UUID.randomUUID().toString());
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertRejected();
+ }
+
+ @Test
+ void testDoFilterMultipleHeadersRejected() throws ServletException,
IOException {
+ setRequest(HttpMethod.POST.name(), SAME_ORIGIN, SAME_ORIGIN);
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertRejected();
+ }
+
+ @Test
+ void testDoFilterMultipleHeaderValuesRejected() throws ServletException,
IOException {
+ setRequest(HttpMethod.POST.name(), String.join(", ", SAME_ORIGIN,
SAME_SITE));
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertRejected();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {SAME_ORIGIN, SAME_SITE, NONE})
+ void testDoFilterAllowed(final String fetchSite) throws ServletException,
IOException {
+ setRequest(HttpMethod.POST.name(), fetchSite);
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertAllowed();
+ }
+
+ @Test
+ void testDoFilterHeaderNotFoundAllowed() throws ServletException,
IOException {
+ setRequest(HttpMethod.POST.name());
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertAllowed();
+ }
+
+ @Test
+ void testDoFilterReplicatedSameOriginAllowed() throws ServletException,
IOException {
+ setRequest(HttpMethod.POST.name(), SAME_ORIGIN);
+
httpServletRequest.addHeader(SkipReplicatedCsrfFilter.REPLICATED_REQUEST_HEADER,
UUID.randomUUID().toString());
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertAllowed();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"GET", "HEAD", "OPTIONS", "TRACE"})
+ void testDoFilterSafeMethodCrossSiteAllowed(final String method) throws
ServletException, IOException {
+ setRequest(method, CROSS_SITE);
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertAllowed();
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = SamlUrlPath.class, names = {"LOGIN_RESPONSE",
"SINGLE_LOGOUT_RESPONSE"})
+ void testDoFilterSamlConsumerCrossSiteAllowed(final SamlUrlPath
samlUrlPath) throws ServletException, IOException {
+ setRequest(HttpMethod.POST.name(), CROSS_SITE);
+ httpServletRequest.setRequestURI(samlUrlPath.getPath());
+
+ filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+ assertAllowed();
+ }
+
+ private void setRequest(final String method, final String...
fetchSiteValues) {
+ httpServletRequest = new MockHttpServletRequest(method, REQUEST_URI);
+ for (final String fetchSiteValue : fetchSiteValues) {
+
httpServletRequest.addHeader(FetchSiteRequestFilter.FETCH_SITE_HEADER,
fetchSiteValue);
+ }
+ }
+
+ private void assertRejected() {
+ assertEquals(HttpServletResponse.SC_FORBIDDEN,
httpServletResponse.getStatus());
+ verifyNoInteractions(filterChain);
+ }
+
+ private void assertAllowed() throws ServletException, IOException {
+ verify(filterChain).doFilter(eq(httpServletRequest),
eq(httpServletResponse));
+ }
+}