fix(security): treat trailing-dot hosts as their blocked names

validateHost and isLoopbackHost matched hostnames by exact string. The
spellings localhost. and metadata.google.internal. passed the SSRF
check. The check now drops the root dot and any IPv6 zone id first.
A bare IPv6 literal without a port no longer fails the host check.
This commit is contained in:
2026-11-03 00:42:00 +00:00
parent 621f7993a8
commit 78db86b455
3 changed files with 39 additions and 3 deletions
+25
View File
@@ -0,0 +1,25 @@
package traefikoidc
import "testing"
func TestValidateHostCanonicalForms(t *testing.T) {
o := &TraefikOidc{}
blocked := []string{
"localhost.", "LOCALHOST.", "metadata.google.internal.", "metadata.google.internal",
"169.254.169.254.", "fe80::1%eth0", "[fe80::1]", "::1",
}
for _, h := range blocked {
if err := o.validateHost(h); err == nil {
t.Errorf("validateHost(%q) = nil, want blocked", h)
}
}
allowed := []string{"idp.example.com", "idp.example.com.", "2606:4700::1111"}
for _, h := range allowed {
if err := o.validateHost(h); err != nil {
t.Errorf("validateHost(%q) = %v, want allowed", h, err)
}
}
if !isLoopbackHost("localhost.") {
t.Error("isLoopbackHost(localhost.) = false")
}
}
+1
View File
@@ -954,6 +954,7 @@ func isValidSecureURL(s string) bool {
// isLoopbackHost reports whether host is "localhost" or a loopback IP literal // isLoopbackHost reports whether host is "localhost" or a loopback IP literal
// (127.0.0.0/8 or ::1). // (127.0.0.0/8 or ::1).
func isLoopbackHost(host string) bool { func isLoopbackHost(host string) bool {
host = canonicalHost(host)
if strings.EqualFold(host, "localhost") { if strings.EqualFold(host, "localhost") {
return true return true
} }
+13 -3
View File
@@ -453,6 +453,15 @@ func sameHost(a, b string) bool {
return strings.EqualFold(ua.Host, ub.Host) return strings.EqualFold(ua.Host, ub.Host)
} }
// canonicalHost drops the root dot ("localhost." is localhost) and an IPv6
// zone id so the blocklists see one spelling per host.
func canonicalHost(host string) string {
if i := strings.IndexByte(host, '%'); i > 0 && net.ParseIP(host[:i]) != nil {
host = host[:i]
}
return strings.TrimRight(host, ".")
}
// validateHost validates a hostname or IP address for security. // validateHost validates a hostname or IP address for security.
// It prevents access to localhost, private networks, and known metadata endpoints. // It prevents access to localhost, private networks, and known metadata endpoints.
// When allowPrivateIPAddresses is enabled, private IP checks are skipped. // When allowPrivateIPAddresses is enabled, private IP checks are skipped.
@@ -462,13 +471,14 @@ func sameHost(a, b string) bool {
// Returns: // Returns:
// - An error if the host is dangerous or not allowed, nil if safe. // - An error if the host is dangerous or not allowed, nil if safe.
func (t *TraefikOidc) validateHost(host string) error { func (t *TraefikOidc) validateHost(host string) error {
hostname := host hostname := canonicalHost(host)
if strings.Contains(host, ":") { if net.ParseIP(hostname) == nil && strings.Contains(hostname, ":") {
var err error var err error
hostname, _, err = net.SplitHostPort(host) hostname, _, err = net.SplitHostPort(hostname)
if err != nil { if err != nil {
return fmt.Errorf("invalid host format: %w", err) return fmt.Errorf("invalid host format: %w", err)
} }
hostname = canonicalHost(hostname)
} }
ip := net.ParseIP(hostname) ip := net.ParseIP(hostname)