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));
+    }
+}

Reply via email to