Setup access denied and csrf

This commit is contained in:
2026-01-05 17:25:12 +01:00
parent f14201b5d1
commit 0035ec2ceb
5 changed files with 119 additions and 2 deletions

View File

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

View File

@@ -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);
};
})();

View File

@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<title>Imgfloat Admin</title>
<meta name="_csrf" th:content="${_csrf.token}" />
<meta name="_csrf_header" th:content="${_csrf.headerName}" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" />
<link
@@ -14,6 +16,7 @@
/>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<script src="/js/csrf.js"></script>
</head>
<body class="admin-body">
<div class="admin-frame">

View File

@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<title>Imgfloat Dashboard</title>
<meta name="_csrf" th:content="${_csrf.token}" />
<meta name="_csrf_header" th:content="${_csrf.headerName}" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
@@ -109,6 +111,7 @@
<section class="card download-card-block" th:insert="fragments/downloads :: downloads"></section>
</div>
<script src="/js/cookie-consent.js"></script>
<script src="/js/csrf.js"></script>
<script src="/js/toast.js"></script>
<script src="/js/downloads.js"></script>
<script th:inline="javascript">

View File

@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<title>Imgfloat Admin</title>
<meta name="_csrf" th:content="${_csrf.token}" />
<meta name="_csrf_header" th:content="${_csrf.headerName}" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" />
<link
@@ -14,6 +16,7 @@
/>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<script src="/js/csrf.js"></script>
</head>
<body class="settings-body">
<div class="settings-shell">