diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java b/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java index 1ef6c593..d382f872 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java @@ -39,17 +39,36 @@ import java.util.Enumeration; * *
See #520. Filter runs at {@code Ordered.HIGHEST_PRECEDENCE} so it * mutates the request before any Spring Security filter sees it. + * + *
Scope: only {@code /api/*} requests are touched. The + * {@code /actuator/*} block in Caddy plus the open auth/reset paths in + * {@link SecurityConfig} must NOT receive a promoted Authorization. + * + *
⚠ Log-leakage warning: the wrapped request exposes the
+ * Authorization header via {@code getHeaderNames}/{@code getHeaders}. Any
+ * filter or interceptor that iterates request headers will see the live
+ * Basic credential. Do NOT add a request-header logger downstream of this
+ * filter without explicitly scrubbing the {@code Authorization} field.
*/
@Component
@Order(org.springframework.core.Ordered.HIGHEST_PRECEDENCE)
public class AuthTokenCookieFilter extends OncePerRequestFilter {
static final String COOKIE_NAME = "auth_token";
+ static final String SCOPE_PREFIX = "/api/";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
+ // Scope: only /api/* needs cookie promotion. /actuator/health (open),
+ // /api/auth/forgot-password (open), /login etc. don't.
+ if (!request.getRequestURI().startsWith(SCOPE_PREFIX)) {
+ chain.doFilter(request, response);
+ return;
+ }
+ // An explicit Authorization header wins — this is the SSR fetch path
+ // (hooks.server.ts builds the header itself).
if (request.getHeader(HttpHeaders.AUTHORIZATION) != null) {
chain.doFilter(request, response);
return;
@@ -60,8 +79,17 @@ public class AuthTokenCookieFilter extends OncePerRequestFilter {
return;
}
for (Cookie c : cookies) {
- if (COOKIE_NAME.equals(c.getName()) && c.getValue() != null && !c.getValue().isEmpty()) {
- String decoded = URLDecoder.decode(c.getValue(), StandardCharsets.UTF_8);
+ if (COOKIE_NAME.equals(c.getName()) && c.getValue() != null && !c.getValue().isBlank()) {
+ String decoded;
+ try {
+ decoded = URLDecoder.decode(c.getValue(), StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException malformed) {
+ // Malformed percent-encoding — refuse to forward a bogus
+ // Authorization header. Spring Security will treat the
+ // request as unauthenticated.
+ chain.doFilter(request, response);
+ return;
+ }
chain.doFilter(new AuthHeaderRequest(request, decoded), response);
return;
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java
index 86672f80..298d9fa6 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java
@@ -37,12 +37,20 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
- // CSRF is intentionally disabled: every request from the SvelteKit frontend
- // carries an explicit Authorization header (Basic Auth token injected by
- // hooks.server.ts). Browsers block cross-origin requests from setting custom
- // headers, so cross-site request forgery via a third-party page is not
- // possible with this auth scheme. If the auth model ever changes to
- // cookie-based sessions, CSRF protection must be re-enabled.
+ // CSRF is intentionally disabled. With the cookie-promotion model
+ // (auth_token cookie → Authorization header via AuthTokenCookieFilter,
+ // see #520), every authenticated request to /api/* now carries the
+ // credential automatically once the cookie is set. The CSRF defence
+ // for state-changing endpoints is therefore LOAD-BEARING on:
+ //
+ // 1. SameSite=strict on the auth_token cookie (login/+page.server.ts).
+ // A cross-site POST from evil.com cannot include the cookie.
+ // 2. CORS — Spring's default rejects cross-origin requests with
+ // credentials unless explicitly allowed (no allowedOrigins config).
+ //
+ // If either of those is ever weakened (e.g. cookie flipped to
+ // SameSite=lax, CORS allowedOrigins expanded), CSRF protection
+ // MUST be re-enabled here.
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> {
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java
index bd301fcd..18ceab49 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java
@@ -29,6 +29,7 @@ class AuthTokenCookieFilterTest {
@Test
void promotes_url_encoded_auth_token_cookie_to_decoded_Authorization_header() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setRequestURI("/api/users/me");
req.setCookies(new Cookie("auth_token", "Basic%20YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDpzZWNyZXQ%3D"));
MockHttpServletResponse res = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
@@ -47,6 +48,7 @@ class AuthTokenCookieFilterTest {
@Test
void preserves_explicit_Authorization_header_and_ignores_cookie() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setRequestURI("/api/users/me");
req.addHeader("Authorization", "Basic explicit-header-wins");
req.setCookies(new Cookie("auth_token", "Basic%20cookie-would-have-promoted"));
MockHttpServletResponse res = new MockHttpServletResponse();
@@ -54,15 +56,14 @@ class AuthTokenCookieFilterTest {
filter.doFilter(req, res, chain);
- ArgumentCaptor