From 0035ec2cebdf382c4a07e4eba8cef55c8e57917a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Mon, 5 Jan 2026 17:25:12 +0100 Subject: [PATCH] Setup access denied and csrf --- .../imgfloat/config/SecurityConfig.java | 71 ++++++++++++++++++- src/main/resources/static/js/csrf.js | 41 +++++++++++ src/main/resources/templates/admin.html | 3 + src/main/resources/templates/dashboard.html | 3 + src/main/resources/templates/settings.html | 3 + 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/static/js/csrf.js diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java index ad761a0..6fb6f55 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java @@ -1,5 +1,7 @@ package dev.kruhlmann.imgfloat.config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -12,15 +14,28 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResp import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfException; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.WebUtils; import org.springframework.web.client.RestTemplate; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { + private static final Logger LOG = LoggerFactory.getLogger(SecurityConfig.class); + @Bean SecurityFilterChain securityFilterChain( HttpSecurity http, @@ -69,9 +84,14 @@ public class SecurityConfig { exceptions.defaultAuthenticationEntryPointFor( new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), new AntPathRequestMatcher("/api/**") - ) + ).accessDeniedHandler(csrfAccessDeniedHandler()) ) - .csrf((csrf) -> csrf.ignoringRequestMatchers("/ws/**", "/api/**")); + .csrf((csrf) -> + csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .ignoringRequestMatchers("/ws/**") + ) + .addFilterAfter(csrfTokenCookieFilter(), CsrfFilter.class); return http.build(); } @@ -89,4 +109,51 @@ public class SecurityConfig { TwitchOAuth2UserService twitchOAuth2UserService() { return new TwitchOAuth2UserService(); } + + private AccessDeniedHandler csrfAccessDeniedHandler() { + return (request, response, accessDeniedException) -> { + if (accessDeniedException instanceof CsrfException) { + LOG.warn( + "CSRF failure for {} {} - referer: {}, origin: {}, message: {}", + request.getMethod(), + request.getRequestURI(), + request.getHeader("Referer"), + request.getHeader("Origin"), + accessDeniedException.getMessage() + ); + } + response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage()); + }; + } + + /** + * Ensure the XSRF-TOKEN cookie is always present for browser clients and mirror the current + * token value on every request. This helps client-side fetch calls include the token header and + * aids debugging when tokens are missing. + */ + @Bean + OncePerRequestFilter csrfTokenCookieFilter() { + return new OncePerRequestFilter() { + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws java.io.IOException, jakarta.servlet.ServletException { + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken != null) { + String token = csrfToken.getToken(); + Cookie existingCookie = WebUtils.getCookie(request, "XSRF-TOKEN"); + if (existingCookie == null || !token.equals(existingCookie.getValue())) { + Cookie cookie = new Cookie("XSRF-TOKEN", token); + cookie.setPath("/"); + cookie.setSecure(request.isSecure()); + cookie.setHttpOnly(false); + response.addCookie(cookie); + } + } + filterChain.doFilter(request, response); + } + }; + } } diff --git a/src/main/resources/static/js/csrf.js b/src/main/resources/static/js/csrf.js new file mode 100644 index 0000000..43664c1 --- /dev/null +++ b/src/main/resources/static/js/csrf.js @@ -0,0 +1,41 @@ +(function () { + const CSRF_COOKIE_NAME = "XSRF-TOKEN"; + const DEFAULT_HEADER_NAME = "X-XSRF-TOKEN"; + const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); + const originalFetch = window.fetch; + + function getCookie(name) { + return document.cookie + .split(";") + .map((c) => c.trim()) + .filter((c) => c.startsWith(name + "=")) + .map((c) => c.substring(name.length + 1))[0]; + } + + function isSameOrigin(url) { + const parsed = new URL(url, window.location.href); + return parsed.origin === window.location.origin; + } + + function getMeta(name) { + const el = document.querySelector(`meta[name=\"${name}\"]`); + return el ? el.getAttribute("content") : null; + } + + window.fetch = function patchedFetch(input, init = {}) { + const request = new Request(input, init); + const method = (request.method || "GET").toUpperCase(); + + if (!SAFE_METHODS.has(method) && isSameOrigin(request.url)) { + const token = getCookie(CSRF_COOKIE_NAME) || getMeta("_csrf"); + const headerName = getMeta("_csrf_header") || DEFAULT_HEADER_NAME; + if (token) { + const headers = new Headers(request.headers || {}); + headers.set(headerName, token); + return originalFetch(new Request(request, { headers })); + } + } + + return originalFetch(request); + }; +})(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 3fad909..7d8d415 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -3,6 +3,8 @@ Imgfloat Admin + + +
diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 6a8fb71..fad44d3 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -3,6 +3,8 @@ Imgfloat Dashboard + + @@ -109,6 +111,7 @@
+ +