fix(security): sanitize WWW-Authenticate realm URL and prevent header injection via X-Forwarded headers
- commit
- 795aa513cafe650be0302b71c367e7f120ef953d
- author
- bnema · · 2026-02-01 14:32 UTC
- parent
- d044953
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
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, | |||
| 87 | 87 | 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 | 105 | // redactUsername partially redacts a username for logging. | |
| 91 | 106 | // Shows first and last character with asterisks in between. | |
| 92 | 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 | 433 | switch authType { | |
| 419 | 434 | 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) | |
| 427 | 444 | 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 { | |
| 431 | 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 | 453 | realm := scheme + "://" + realmHost + "/auth/token" | |
| 434 | 454 | w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`) | |
| 435 | 455 | default: | |
| @@ -87,6 +87,21 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, | |||||
| 87 | return authenticated | 87 | 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 endpoint | 435 | + | // For token auth, indicate the token server endpoint. |
| 421 | - | // Use X-Forwarded-Host if behind proxy, otherwise use Host header | 436 | + | // 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 = host | 439 | + | // 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: | ||