Created
February 7, 2026 14:38
-
-
Save rferreira/47cc9c58ae12ac3db8fc656a4598fc61 to your computer and use it in GitHub Desktop.
Spring Boot CSRF protection leveraging Sec-Fetch-Site header
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.uvasoftware.noni.server.spring.csrf; | |
| import java.io.IOException; | |
| import java.net.URI; | |
| import java.util.Set; | |
| import jakarta.servlet.FilterChain; | |
| import jakarta.servlet.ServletException; | |
| import jakarta.servlet.http.HttpServletRequest; | |
| import jakarta.servlet.http.HttpServletResponse; | |
| import org.slf4j.Logger; | |
| import org.slf4j.LoggerFactory; | |
| import org.springframework.http.HttpHeaders; | |
| import org.springframework.stereotype.Component; | |
| import org.springframework.util.AntPathMatcher; | |
| import org.springframework.web.filter.OncePerRequestFilter; | |
| /// Minimal CSRF protection leveraging Sec-Fetch-Site header as per Fetch Metadata Request Headers. | |
| /// Blocks state-changing requests (POST/PUT/PATCH/DELETE) when Sec-Fetch-Site indicates cross-site. | |
| /// Also allows requests when Sec-Fetch-Site is missing, but Origin matches the server host. | |
| /// | |
| /// Ported from [original](https://github.com/golang/go/blob/master/src/net/http/csrf.go) | |
| /// | |
| /// Relevant documentation: | |
| /// * [issue](https://github.com/golang/go/issues/73626) | |
| /// * [OWASP](https://github.com/OWASP/CheatSheetSeries/issues/1803) | |
| /// * [Sec-Fetch-Site](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) | |
| /// * [Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin) | |
| /// * [Cross-Site Request Forgery(CSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF) | |
| /// * [safe methods](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP) | |
| /// | |
| /// Note: you should couple this with same-site lax or strict on session cookies. | |
| @Component | |
| class SecFetchSiteCsrfFilter extends OncePerRequestFilter { | |
| private static final Logger LOG = LoggerFactory.getLogger(SecFetchSiteCsrfFilter.class); | |
| private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS"); | |
| private final SecFetchSiteCsrfConfiguration config; | |
| SecFetchSiteCsrfFilter() { | |
| this.config = new SecFetchSiteCsrfConfiguration(); | |
| } | |
| SecFetchSiteCsrfFilter(SecFetchSiteCsrfConfiguration config) { | |
| this.config = config; | |
| } | |
| @Override | |
| protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) | |
| throws ServletException, IOException { | |
| final String method = req.getMethod(); | |
| // Allow bypass based on path patterns | |
| final String path = req.getRequestURI(); | |
| var pathMatcher = new AntPathMatcher(); | |
| for (String p : config.getBypassPatterns()) { | |
| if (pathMatcher.match(p, req.getServletPath())) { | |
| LOG.debug("bypassing CSRF filter due to path match: pattern={} path={} method={}", p, path, method); | |
| chain.doFilter(req, resp); | |
| return; | |
| } | |
| } | |
| // Always allow safe methods | |
| if (SAFE_METHODS.contains(method)) { | |
| chain.doFilter(req, resp); | |
| return; | |
| } | |
| // For all other methods (state-changing or unknown), enforce protection | |
| var secFetchSite = req.getHeader("Sec-Fetch-Site"); | |
| switch (secFetchSite) { | |
| case null -> { | |
| // No Sec-Fetch-Site header present; fall through to Origin check below | |
| } | |
| case "same-origin", "none" -> { | |
| LOG.debug("allowed request: Sec-Fetch-Site={} method={}", secFetchSite, method); | |
| chain.doFilter(req, resp); | |
| return; | |
| } | |
| default -> { | |
| LOG.warn("blocking request with unknown Sec-Fetch-Site={} method={}", secFetchSite, method); | |
| resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Request blocked by CSRF filter"); | |
| return; | |
| } | |
| } | |
| final String origin = req.getHeader(HttpHeaders.ORIGIN); | |
| final String host = req.getHeader(HttpHeaders.HOST); | |
| if (origin == null || origin.isBlank()) { | |
| // Neither Sec-Fetch-Site nor Origin headers are present. | |
| // Either the request is same-origin or not a browser request. | |
| LOG.debug("allowed request: no Origin header and no Sec-Fetch-Site method={}", method); | |
| chain.doFilter(req, resp); | |
| return; | |
| } | |
| try { | |
| var originURI = URI.create(origin); | |
| if (originURI.getHost().equals(host)) { | |
| // The Origin header matches the Host header. Note that the Host header | |
| // doesn't include the scheme, so we don't know if this might be an | |
| // HTTP→HTTPS cross-origin request. We fail open, since all modern | |
| // browsers support Sec-Fetch-Site since 2023, and running an older | |
| // browser makes a clear security trade-off already. Sites can mitigate | |
| // this with HTTP Strict Transport Security (HSTS). | |
| chain.doFilter(req, resp); | |
| return; | |
| } | |
| } | |
| catch (IllegalArgumentException e) { | |
| LOG.warn("Error performing origin validation: origin={} host={}", origin, host, e); | |
| } | |
| LOG.warn("Blocking cross-origin request: method={} origin={} host={}", method, origin, host); | |
| resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Cross-origin request blocked by CSRF filter"); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment