fix(auth): derive token realm scheme from the request

Context

AR's Docker-registry token flow advertised a hardcoded plain-HTTP realm. internal/auth/dispatch.go held const tokenEndpointScheme = "http://", and realmFromHost in internal/auth/middleware.go built the realm as tokenEndpointScheme + host + v2AuthTokenPath. The service never inspected X-Forwarded-Proto or r.TLS, so behind a TLS-terminating proxy (every self-managed and production deployment) the WWW-Authenticate header read Bearer realm="http://<host>/v2/auth/token".

Two observed consequences (reproduced against a live deployment behind nginx-ingress with TLS):

  • crane (go-containerregistry) rejects the registry outright: invalid realm in www-authenticate: realm scheme "http" not allowed for a secure registry; use https.
  • Clients that honor the realm (docker, oras) send Basic credentials to a cleartext URL, exposing them.

Fix

Derive the realm scheme per request in a new realmScheme helper:

  • https when the request itself is TLS (r.TLS != nil). This is authoritative and wins: r.TLS is negotiated by the server and cannot be forged, so a client-supplied X-Forwarded-Proto: http cannot downgrade a direct-TLS realm to a cleartext URL (flagged by the AppSec review).
  • Else a valid X-Forwarded-Proto (http or https, first value if the proxy chained several, case-insensitive) applies — the reverse-proxy case.
  • Else http.

Any other X-Forwarded-Proto value is ignored and the fallback applies, so a malformed header cannot inject a bogus scheme. realmFromHost takes the derived scheme and builds scheme + "://" + host + v2AuthTokenPath; the empty-Host and invalid-Host safe-literal fallbacks are unchanged.

Test coverage

TestMiddleware_RealmSchemeFromRequest drives the full auth middleware and asserts the WWW-Authenticate realm scheme for the /v2/ base row:

  • X-Forwarded-Proto: https produces an https realm.
  • Mixed-case X-Forwarded-Proto: HTTPS produces an https realm (case-insensitive).
  • A direct TLS request (r.TLS != nil) produces an https realm.
  • X-Forwarded-Proto: http on a direct TLS request cannot downgrade the realm; it stays https.
  • A plain request without the header stays http.
  • A chained X-Forwarded-Proto: https, http uses the first value; a malformed first element (,https) falls back to http.
  • The /v2/<slug>/container/... row derives the scheme the same way as the /v2/ base row.
  • A garbage X-Forwarded-Proto value falls back to http.

Each behavior-changing row was written first and confirmed failing before its fix. Existing realm assertions (internal/server/server_test.go, the auth package's wantChallengeV2* constants) are plain-HTTP requests with no forwarded header, so they stay correct under the new fallback and need no change. go test ./internal/auth/... ./internal/server/... and golangci-lint run ./internal/auth/... both pass.

Spec

docs/specs/S08-authentication.md token-endpoint definition updated to document the per-request scheme derivation, the r.TLS-first precedence, and the deployment prerequisite that the TLS-terminating proxy must set or override X-Forwarded-Proto. docs/specs/S12-container-oci-hosted.md uses <token-endpoint> placeholders and defers the realm format to S08/ADR-020; it pins no scheme, so it needed no change.

Research

Reference implementation (Container Registry): it never derives the realm scheme from the request. The realm is either a static operator-configured URL (auth.token.realm) or, with autoredirect: true, the hardcoded https://<host>/auth/token (registry/auth/token/accesscontroller.go). Its general URL builder (registry/api/urls/urls.go) does derive the scheme per request, but lets forwarded headers override r.TLS and accepts any header value verbatim, the exact weaknesses the AppSec review flagged here. AR keeps per-request derivation (no new configuration knob, and plain-HTTP local development keeps working) with a tighter trust model.

Best-practice guidance converges on: never trust X-Forwarded-Proto blindly (it is client-spoofable); validate the value against an http/https allowlist; expect a single value, with proxies overwriting rather than appending (and take the first value when a chain appears anyway); honor the header inside a trusted-proxy boundary and fall back to the connection's own TLS state outside it; and use the derived scheme for URL generation, not access control. This MR implements all of that except a trusted-proxy address list. Today the derived scheme feeds exactly one thing, the advertised realm URL; authorization never consults it, and AR's storage redirects are built from the storage and CDN configuration, not from the request. A trusted-proxy list becomes necessary only if the derived scheme ever feeds a security-bearing decision.

References:

Note

This is not a security incident: AR is not yet released, so no users or production deployments are exposed. The MR stays on the public repository.

Closes #304 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading