diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java b/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java new file mode 100644 index 00000000..d382f872 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilter.java @@ -0,0 +1,137 @@ +package org.raddatz.familienarchiv.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Enumeration; + +/** + * Promotes the {@code auth_token} cookie to an {@code Authorization} header + * so that browser-side requests to {@code /api/*} authenticate the same way + * SSR fetches do. + * + *

The SvelteKit login action stores the full HTTP Basic header value + * ({@code "Basic "}) in an HttpOnly cookie. SSR fetches from + * {@code hooks.server.ts} read the cookie and pass it explicitly as the + * {@code Authorization} header. In the dev environment, Vite's proxy does + * the same on every {@code /api/*} request (see {@code vite.config.ts}). + * In production, Caddy proxies {@code /api/*} straight to the backend and + * does NOT translate the cookie — so client-side {@code fetch} and + * {@code EventSource} calls reach the backend without auth, get + * {@code 401 WWW-Authenticate: Basic}, and the browser pops a native dialog. + * + *

This filter closes that gap: if a request has an {@code auth_token} + * cookie but no explicit {@code Authorization} header, promote the cookie + * value (URL-decoded) into the header before Spring Security inspects it. + * Explicit {@code Authorization} headers are preserved unchanged. + * + *

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; + } + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + chain.doFilter(request, response); + return; + } + for (Cookie c : cookies) { + 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; + } + } + chain.doFilter(request, response); + } + + /** + * Adds (or overrides) the {@code Authorization} header on a wrapped request. + * All other headers pass through unchanged. + */ + static final class AuthHeaderRequest extends HttpServletRequestWrapper { + private final String authorization; + + AuthHeaderRequest(HttpServletRequest request, String authorization) { + super(request); + this.authorization = authorization; + } + + @Override + public String getHeader(String name) { + if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) { + return authorization; + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaders(String name) { + if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) { + return Collections.enumeration(Collections.singletonList(authorization)); + } + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + Enumeration base = super.getHeaderNames(); + java.util.Set names = new java.util.LinkedHashSet<>(); + while (base.hasMoreElements()) names.add(base.nextElement()); + names.add(HttpHeaders.AUTHORIZATION); + return Collections.enumeration(names); + } + } +} 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/document/ThumbnailServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/ThumbnailServiceIntegrationTest.java index ce198a7c..cfd7f0f4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/ThumbnailServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/ThumbnailServiceIntegrationTest.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.document.DocumentStatus; import org.raddatz.familienarchiv.document.DocumentRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import org.springframework.context.annotation.Import; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -41,6 +42,7 @@ import static org.assertj.core.api.Assertions.assertThat; * test pyramid mocks at the FileService boundary. */ @SpringBootTest +@ActiveProfiles("test") @Import(PostgresContainerConfig.class) class ThumbnailServiceIntegrationTest { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java new file mode 100644 index 00000000..18ceab49 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/security/AuthTokenCookieFilterTest.java @@ -0,0 +1,134 @@ +package org.raddatz.familienarchiv.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * The filter must turn a browser-side {@code Cookie: auth_token=Basic%20} + * into {@code Authorization: Basic } (URL-decoded) so that Spring's + * Basic-auth filter accepts it. Skips when the request already has an explicit + * {@code Authorization} header, or when no {@code auth_token} cookie is present. + * + *

See #520. + */ +class AuthTokenCookieFilterTest { + + private final AuthTokenCookieFilter filter = new AuthTokenCookieFilter(); + + @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); + + filter.doFilter(req, res, chain); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpServletRequest.class); + verify(chain, times(1)).doFilter(captor.capture(), org.mockito.ArgumentMatchers.any(HttpServletResponse.class)); + + HttpServletRequest forwarded = captor.getValue(); + assertThat(forwarded.getHeader("Authorization")) + .as("Authorization must be URL-decoded so Spring's Basic parser sees a literal space") + .isEqualTo("Basic YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDpzZWNyZXQ="); + } + + @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(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + // Forwards the original request unchanged — same instance, no wrapping. + verify(chain).doFilter(req, res); + } + + @Test + void passes_through_when_no_cookies_at_all() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/users/me"); + MockHttpServletResponse res = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + } + + @Test + void passes_through_when_auth_token_cookie_is_absent() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/users/me"); + req.setCookies(new Cookie("some_other_cookie", "value")); + MockHttpServletResponse res = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + } + + @Test + void passes_through_when_auth_token_cookie_is_empty() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/users/me"); + req.setCookies(new Cookie("auth_token", "")); + MockHttpServletResponse res = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + } + + @Test + void passes_through_unchanged_when_request_is_outside_api_scope() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + // /actuator/health and similar must NOT receive a promoted Authorization + // header — they have their own access rules and should never be authed + // via the cookie. + req.setRequestURI("/actuator/health"); + req.setCookies(new Cookie("auth_token", "Basic%20YWR==")); + MockHttpServletResponse res = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + // Forwards the original request unchanged — same instance, no wrapping. + verify(chain).doFilter(req, res); + } + + @Test + void passes_through_unchanged_when_cookie_value_is_malformed_percent_encoding() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/users/me"); + // Lone "%" without two hex digits → URLDecoder throws → filter must + // refuse to forward a bogus Authorization header. + req.setCookies(new Cookie("auth_token", "Basic%2")); + MockHttpServletResponse res = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + // Forwards the original request unchanged — Spring Security treats it + // as unauthenticated rather than crashing on bad input. + verify(chain).doFilter(req, res); + } +} diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index d3e22098..50aabec5 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -8,7 +8,7 @@ export const load: PageServerLoad = ({ url }) => { }; export const actions = { - login: async ({ request, cookies, fetch }) => { + login: async ({ request, cookies, fetch, url }) => { const data = await request.formData(); const email = data.get('email') as string; const password = data.get('password') as string; @@ -37,11 +37,17 @@ export const actions = { return fail(500, { error: getErrorMessage('INTERNAL_ERROR') }); } + // The cookie IS the API credential — promoted to `Authorization: Basic …` + // on every browser → backend request by AuthTokenCookieFilter on the + // Spring side (see #520). It must be Secure on HTTPS or it leaks + // a 24h Basic token on plaintext networks. Dev runs over HTTP and + // would silently lose the cookie if we hardcoded secure=true. + const isHttps = url.protocol === 'https:'; cookies.set('auth_token', authHeader, { path: '/', httpOnly: true, sameSite: 'strict', - secure: false, // set to true when HTTPS is available + secure: isHttps, maxAge: 60 * 60 * 24 }); } catch (e) {