Skip to content

Instantly share code, notes, and snippets.

@rferreira
Created February 7, 2026 14:38
Show Gist options
  • Select an option

  • Save rferreira/47cc9c58ae12ac3db8fc656a4598fc61 to your computer and use it in GitHub Desktop.

Select an option

Save rferreira/47cc9c58ae12ac3db8fc656a4598fc61 to your computer and use it in GitHub Desktop.
Spring Boot CSRF protection leveraging Sec-Fetch-Site header
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