From 78db86b4559f9aa12c33d095fcc98e7d7e885187 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 3 Nov 2026 00:42:00 +0000 Subject: [PATCH] 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. --- host_canon_test.go | 25 +++++++++++++++++++++++++ settings.go | 1 + url_helpers.go | 16 +++++++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 host_canon_test.go diff --git a/host_canon_test.go b/host_canon_test.go new file mode 100644 index 0000000..2400157 --- /dev/null +++ b/host_canon_test.go @@ -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") + } +} diff --git a/settings.go b/settings.go index 4df11a6..309b797 100644 --- a/settings.go +++ b/settings.go @@ -954,6 +954,7 @@ func isValidSecureURL(s string) bool { // isLoopbackHost reports whether host is "localhost" or a loopback IP literal // (127.0.0.0/8 or ::1). func isLoopbackHost(host string) bool { + host = canonicalHost(host) if strings.EqualFold(host, "localhost") { return true } diff --git a/url_helpers.go b/url_helpers.go index f3714e9..0c5ddbf 100644 --- a/url_helpers.go +++ b/url_helpers.go @@ -453,6 +453,15 @@ func sameHost(a, b string) bool { 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. // It prevents access to localhost, private networks, and known metadata endpoints. // When allowPrivateIPAddresses is enabled, private IP checks are skipped. @@ -462,13 +471,14 @@ func sameHost(a, b string) bool { // Returns: // - An error if the host is dangerous or not allowed, nil if safe. func (t *TraefikOidc) validateHost(host string) error { - hostname := host - if strings.Contains(host, ":") { + hostname := canonicalHost(host) + if net.ParseIP(hostname) == nil && strings.Contains(hostname, ":") { var err error - hostname, _, err = net.SplitHostPort(host) + hostname, _, err = net.SplitHostPort(hostname) if err != nil { return fmt.Errorf("invalid host format: %w", err) } + hostname = canonicalHost(hostname) } ip := net.ParseIP(hostname)