Skip to main content

Browser trust boundary (M33-013)

Three related decisions about what the browser is trusted with, settled together because deciding any one of them alone gets the others wrong.

1. CORS: same-origin, and the config is gone

Decision: the platform is served same-origin. There is no CORS policy, and that is deliberate.

values.yaml publishes a single host and path-routes /api, /actuator, /swagger-ui, /v3/api-docs and /webjars to the backend; everything else reaches the frontend. Browser and API therefore share an origin and no preflight is ever issued.

CORS_ALLOWED_ORIGINS previously appeared in docker-compose.yml, docker-compose.e2e.yml, the Dockerfile, both .env examples and all three Helm values files, and was read by no Java code at all. One of the values even named api.uhp.zhenus.local, a host the ingress never served.

That is worse than having nothing. Dead security configuration answers "is CORS handled here?" with a yes nobody verified, and it did exactly that to a reconnaissance pass of this codebase.

⚠⚠ This section previously claimed all of it was removed. It was not. The Java was clean and CorsIsDeliberatelyAbsentTest guarded it, but values.yaml, values-dev.yaml, values-prod.yaml and the local-deployment runbook still set CORS_ALLOWED_ORIGINS — found 2026-09-10. A document and a test were both vouching for a control the tree contradicted, which is worse than the original dead config, because it had evidence behind it.

Two guards now, because one could never have caught this:

guardcovers
CorsIsDeliberatelyAbsentTestthe Java — no CorsConfigurationSource, no addCorsMappings, no @CrossOrigin
CorsConfigStaysDeadGateTestevery file in the repository — no deployment values, compose file, Dockerfile or runbook sets CORS_ALLOWED_ORIGINS

⚠ The second allows a short, named list of documents to mention the setting precisely because they record its removal. That list is by filename, not "all markdown": a blanket exemption would hide it reappearing in an operator runbook, which is how it would come back.

values-prod.yaml described a split-host topology that nothing deploys. If split-host is ever adopted, that test must fail and be replaced deliberately — with an allowlist and allowCredentials decided on purpose, never allowedOrigins: ["*"] beside credentials.

2. CSRF stays disabled, and here is why

Decision: ratified, not reversed.

Every state-changing endpoint authenticates by Authorization: Bearer, which a cross-site form cannot set. The one cookie-authenticated endpoint is POST /api/v1/auth/refresh, and the refresh cookie is SameSite=Lax, which browsers withhold on cross-site POST.

That reasoning holds only while the platform is same-origin. It becomes fragile the moment CORS allows credentials from another origin — which is precisely why the CORS decision above and this one had to be made in the same ticket. Anyone introducing CORS must revisit CSRF in the same change.

Decision: adopted, and the path widened to / to go with it.

__Host- guarantees a cookie can only be set by the exact host that served the response, over HTTPS, with no Domain. It closes subdomain cookie-fixation: nothing on a sibling or parent name can plant a refresh cookie for this host.

The trade is that __Host- requires Path=/, widening the cookie from /api/v1/auth to every request. That costs less than it appears: Path is not a security boundary. Same-origin script can post to /api/v1/auth whatever the cookie's path is, so the narrow path was never protecting anything, while the prefix protects something real.

The prefix is ignored whenever secure is false, and this is load-bearing. A browser silently rejects a __Host- cookie sent without Secure — no warning, no downgrade, the cookie simply never exists. On a local HTTP stack that presents as "login succeeded, then every refresh failed". The name and the path are therefore both derived from secure and move together; RefreshCookieNamingTest pins that a prefixed name never keeps the narrow path.

Sessions issued before this change stop refreshing. The cookie is read back under the prefixed name only — the plain name is not also accepted, because a rollover window honouring an unprefixed cookie would honour exactly the subdomain-planted cookie the prefix exists to refuse. Users sign in once more after deployment.

4. CSP enforcement

Not in this document: the CSP moved from reporting-nowhere to reporting-to-the-collector in M33-012C, and enforcement is gated on a representative window of real violations. See the CSP section of the security runbook.