bug(security): browser-side /api/* requests miss Authorization in production → browser shows Basic-auth popup #520

Closed
opened 2026-05-11 17:31:58 +02:00 by marcel · 0 comments
Owner

Summary

The auth model assumes every /api/* request carries an Authorization: Basic <base64> header. The login action sets an HttpOnly auth_token cookie. In dev, Vite's proxy (frontend/vite.config.ts) reads auth_token and injects the header on every /api/* request. In production, Caddy proxies /api/* straight to the backend with no header injection. Result:

  • Browser-side fetch('/api/...') (15+ call sites, including the documents page transcription editor and the OCR progress poller) hit the backend without Authorization.
  • Two EventSource connections (/api/notifications/stream, /api/ocr/jobs/.../progress) hit the backend without Authorization.
  • Backend returns 401 WWW-Authenticate: Basic realm="Realm"browser shows a native Basic-auth popup every time the page makes a client-side call.

Why nobody caught this before

This is the first production-style deploy of #497. Local dev's Vite proxy hides the problem. The deploy smoke test only hits /login, /, and /actuator/health — never an authenticated /api/* from the browser context.

Fix

Backend OncePerRequestFilter that promotes the auth_token cookie to an Authorization header before Spring Security's filter chain runs. URL-decodes the cookie value (the login action URL-encodes "Basic " → "Basic%20"). Only applies if no explicit Authorization header is already present.

This makes the backend work the same way for:

  • SSR fetches from SvelteKit (hooks.server.ts injects the header explicitly)
  • Dev mode (Vite proxy injects)
  • Production browser → Caddy → backend (this filter promotes the cookie)
  • SSE connections from the browser

No Caddy changes, no client-side code changes, no protocol changes. One filter class + one slice test.

Alternatives considered

  • Caddy header_up Authorization {http.request.cookie.auth_token} — fails because Caddy doesn't URL-decode the cookie value and Spring's Basic parser rejects Basic%20YWR….
  • Change cookie format to bare base64 + reconstruct Authorization downstream — would need coordinated changes to login action, both SvelteKit hooks, vite config, and Caddyfile. High blast radius for a runtime fix.
  • Disable WWW-Authenticate header (custom AuthenticationEntryPoint) — hides the popup but leaves the /api/* calls actually broken; user sees empty pages instead.

Discovered

Logging into the live staging stack after #514 / #517 / #518 unblocked basic auth — login lands the dashboard, then a popup fires on the first SSE/notification fetch.

## Summary The auth model assumes every `/api/*` request carries an `Authorization: Basic <base64>` header. The login action sets an HttpOnly `auth_token` cookie. In **dev**, Vite's proxy (`frontend/vite.config.ts`) reads `auth_token` and injects the header on every `/api/*` request. **In production, Caddy proxies `/api/*` straight to the backend with no header injection.** Result: - Browser-side `fetch('/api/...')` (15+ call sites, including the documents page transcription editor and the OCR progress poller) hit the backend without `Authorization`. - Two `EventSource` connections (`/api/notifications/stream`, `/api/ocr/jobs/.../progress`) hit the backend without `Authorization`. - Backend returns `401 WWW-Authenticate: Basic realm="Realm"` → **browser shows a native Basic-auth popup** every time the page makes a client-side call. ## Why nobody caught this before This is the first production-style deploy of #497. Local dev's Vite proxy hides the problem. The deploy smoke test only hits `/login`, `/`, and `/actuator/health` — never an authenticated `/api/*` from the browser context. ## Fix Backend `OncePerRequestFilter` that promotes the `auth_token` cookie to an `Authorization` header before Spring Security's filter chain runs. URL-decodes the cookie value (the login action URL-encodes "Basic " → "Basic%20"). Only applies if no explicit `Authorization` header is already present. This makes the backend work the same way for: - SSR fetches from SvelteKit (`hooks.server.ts` injects the header explicitly) - Dev mode (Vite proxy injects) - **Production browser → Caddy → backend** (this filter promotes the cookie) - SSE connections from the browser No Caddy changes, no client-side code changes, no protocol changes. One filter class + one slice test. ## Alternatives considered - **Caddy `header_up Authorization {http.request.cookie.auth_token}`** — fails because Caddy doesn't URL-decode the cookie value and Spring's Basic parser rejects `Basic%20YWR…`. - **Change cookie format to bare base64 + reconstruct Authorization downstream** — would need coordinated changes to login action, both SvelteKit hooks, vite config, and Caddyfile. High blast radius for a runtime fix. - **Disable `WWW-Authenticate` header (custom AuthenticationEntryPoint)** — hides the popup but leaves the `/api/*` calls actually broken; user sees empty pages instead. ## Discovered Logging into the live staging stack after #514 / #517 / #518 unblocked basic auth — login lands the dashboard, then a popup fires on the first SSE/notification fetch.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#520