detached e27d337

fix(security): sanitize WWW-Authenticate realm URL and prevent header injection via X-Forwarded headers

commit
e27d337bd3fa11ba0ece0c1b585bb01ad89ab35b
author
bnema · · 2026-02-01 14:32 UTC
parent
23a6a7a
signature
unsigned
1 file · +30 -10
M internal/adapters/in/http/middleware/auth.go +30 -10
@@ -87,6 +87,21 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
8787 return authenticated
8888 }
8989
90+// sanitizeHeaderValue removes characters that could enable header injection.
91+// Only allows alphanumeric, dots, hyphens, colons, and square brackets
92+// (sufficient for host:port and IPv6 addresses).
93+func sanitizeHeaderValue(value string) string {
94+ var b strings.Builder
95+ b.Grow(len(value))
96+ for _, r := range value {
97+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
98+ r == '.' || r == '-' || r == ':' || r == '[' || r == ']' {
99+ b.WriteRune(r)
100+ }
101+ }
102+ return b.String()
103+}
104+
90105 // redactUsername partially redacts a username for logging.
91106 // Shows first and last character with asterisks in between.
92107 // SECURITY: Prevents full username exposure in logs.
@@ -417,19 +432,24 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri
417432
418433 switch authType {
419434 case domain.AuthTypeToken:
420- // For token auth, indicate the token server endpoint
421- // Use X-Forwarded-Host if behind proxy, otherwise use Host header
422- realmHost := r.Header.Get("X-Forwarded-Host")
423- if realmHost == "" {
424- realmHost = host
425- }
426- // Detect scheme: prioritize X-Forwarded-Proto (set by reverse proxies like Cloudflare)
435+ // For token auth, indicate the token server endpoint.
436+ // SECURITY: Only use X-Forwarded-Host/Proto from trusted sources.
437+ // The Host header from the request is used as the default.
438+ // X-Forwarded-Host is NOT trusted here because this middleware doesn't
439+ // have access to trusted proxy configuration, and an attacker could
440+ // inject a malicious realm URL to phish for credentials.
441+ realmHost := host
442+
443+ // Detect scheme from TLS state only (not from headers that can be spoofed)
427444 scheme := "http"
428- if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
429- scheme = proto
430- } else if r.TLS != nil {
445+ if r.TLS != nil {
431446 scheme = "https"
432447 }
448+
449+ // SECURITY: Sanitize the host to prevent header injection.
450+ // Remove any characters that could break the WWW-Authenticate header format.
451+ realmHost = sanitizeHeaderValue(realmHost)
452+
433453 realm := scheme + "://" + realmHost + "/auth/token"
434454 w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
435455 default:
@@ -87,6 +87,21 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
87 return authenticated87 return authenticated
88 }88 }
89 89
90+// sanitizeHeaderValue removes characters that could enable header injection.
91+// Only allows alphanumeric, dots, hyphens, colons, and square brackets
92+// (sufficient for host:port and IPv6 addresses).
93+func sanitizeHeaderValue(value string) string {
94+ var b strings.Builder
95+ b.Grow(len(value))
96+ for _, r := range value {
97+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
98+ r == '.' || r == '-' || r == ':' || r == '[' || r == ']' {
99+ b.WriteRune(r)
100+ }
101+ }
102+ return b.String()
103+}
104+
90 // redactUsername partially redacts a username for logging.105 // redactUsername partially redacts a username for logging.
91 // Shows first and last character with asterisks in between.106 // Shows first and last character with asterisks in between.
92 // SECURITY: Prevents full username exposure in logs.107 // SECURITY: Prevents full username exposure in logs.
@@ -417,19 +432,24 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri
417 432
418 switch authType {433 switch authType {
419 case domain.AuthTypeToken:434 case domain.AuthTypeToken:
420- // For token auth, indicate the token server endpoint435+ // For token auth, indicate the token server endpoint.
421- // Use X-Forwarded-Host if behind proxy, otherwise use Host header436+ // SECURITY: Only use X-Forwarded-Host/Proto from trusted sources.
422- realmHost := r.Header.Get("X-Forwarded-Host")437+ // The Host header from the request is used as the default.
423- if realmHost == "" {438+ // X-Forwarded-Host is NOT trusted here because this middleware doesn't
424- realmHost = host439+ // have access to trusted proxy configuration, and an attacker could
425- }440+ // inject a malicious realm URL to phish for credentials.
426- // Detect scheme: prioritize X-Forwarded-Proto (set by reverse proxies like Cloudflare)441+ realmHost := host
442+
443+ // Detect scheme from TLS state only (not from headers that can be spoofed)
427 scheme := "http"444 scheme := "http"
428- if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {445+ if r.TLS != nil {
429- scheme = proto
430- } else if r.TLS != nil {
431 scheme = "https"446 scheme = "https"
432 }447 }
448+
449+ // SECURITY: Sanitize the host to prevent header injection.
450+ // Remove any characters that could break the WWW-Authenticate header format.
451+ realmHost = sanitizeHeaderValue(realmHost)
452+
433 realm := scheme + "://" + realmHost + "/auth/token"453 realm := scheme + "://" + realmHost + "/auth/token"
434 w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)454 w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
435 default:455 default:

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close