fixup: address Nora's review on #520 (security blockers)
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m48s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m10s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Successful in 56s

- frontend/login: derive cookie `secure` flag from request URL protocol.
  Pre-PR the cookie was only read by SSR so the flag didn't matter; now
  the cookie IS the API credential and 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`, so the
  flag follows `event.url.protocol === 'https:'`.

- SecurityConfig: rewrite the CSRF-disabled comment. The old
  "browsers block cross-origin custom headers" justification no longer
  holds once /api/* is authenticated via the cookie. Make the
  load-bearing dependencies explicit: SameSite=strict on the auth_token
  cookie + Spring's default CORS rejection.

- AuthTokenCookieFilter:
  - Scope to /api/* only. /actuator/health and similar must not be
    cookie-authenticated.
  - Refuse malformed percent-encoding (URLDecoder throws); forward the
    request without a promoted Authorization rather than crash.
  - Use isBlank() instead of isEmpty() per Nora.
  - Javadoc warning: getHeaderNames/getHeaders exposes the Basic
    credential; any future header-iterating logger must scrub
    Authorization before logging.

- Tests: add `passes_through_unchanged_when_request_is_outside_api_scope`
  (/actuator/health with cookie should NOT be wrapped) and
  `passes_through_unchanged_when_cookie_value_is_malformed_percent_encoding`.
  Tighten the explicit-header test to verify same-instance forwarding
  rather than just header equality.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #521.
This commit is contained in:
Marcel
2026-05-11 17:49:27 +02:00
committed by marcel
parent 023810df1e
commit 48c8bb8a5f
4 changed files with 93 additions and 14 deletions

View File

@@ -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<HttpServletRequest> captor = ArgumentCaptor.forClass(HttpServletRequest.class);
verify(chain).doFilter(captor.capture(), org.mockito.ArgumentMatchers.any(HttpServletResponse.class));
assertThat(captor.getValue().getHeader("Authorization"))
.isEqualTo("Basic explicit-header-wins");
// 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);
@@ -74,6 +75,7 @@ class AuthTokenCookieFilterTest {
@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);
@@ -86,6 +88,7 @@ class AuthTokenCookieFilterTest {
@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);
@@ -94,4 +97,38 @@ class AuthTokenCookieFilterTest {
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);
}
}