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:
httpswhen the request itself is TLS (r.TLS != nil). This is authoritative and wins:r.TLSis negotiated by the server and cannot be forged, so a client-suppliedX-Forwarded-Proto: httpcannot downgrade a direct-TLS realm to a cleartext URL (flagged by the AppSec review).- Else a valid
X-Forwarded-Proto(httporhttps, 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: httpsproduces anhttpsrealm.- Mixed-case
X-Forwarded-Proto: HTTPSproduces anhttpsrealm (case-insensitive). - A direct TLS request (
r.TLS != nil) produces anhttpsrealm. X-Forwarded-Proto: httpon a direct TLS request cannot downgrade the realm; it stayshttps.- A plain request without the header stays
http. - A chained
X-Forwarded-Proto: https, httpuses the first value; a malformed first element (,https) falls back tohttp. - The
/v2/<slug>/container/...row derives the scheme the same way as the/v2/base row. - A garbage
X-Forwarded-Protovalue falls back tohttp.
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:
- MDN:
X-Forwarded-Proto(spoofable; single protocol identifier) - RFC 7239: Forwarded HTTP Extension
- Django
SECURE_PROXY_SSL_HEADER(opt-in, exact-value match) - Express
trust proxy(trusted-proxy gating of forwarded headers) - Keycloak
proxy-trusted-addresses(trusted-proxy CIDR model) - AWS ELB
X-Forwarded-*headers (proxies overwrite with the observed protocol)
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)