VAULT-44213: Fix Issuer Normalization (#14618) (#15087) (#15443)

* first pass

* move function into method

* add additional tests and path normalization logic

* move normalization functions to helper package and update references

* actually commit the helper package

* copywrite headers

* update references in test

* kicking CI

---------

Co-authored-by: davidadeleon <56207066+davidadeleon@users.noreply.github.com>
Co-authored-by: davidadeleon <ddeleon@hashicorp.com>
This commit is contained in:
Vault Automation 2026-06-11 13:09:15 -06:00 committed by GitHub
parent 78357295f8
commit ab0fa8cc3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 363 additions and 0 deletions

144
helper/jwt/jwt.go Normal file
View file

@ -0,0 +1,144 @@
// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: BUSL-1.1
package jwt
import (
"fmt"
"net/url"
"path"
"strings"
)
// normalizeIssuer normalizes an issuer URL according to RFC 9207 and RFC 3986.
//
// RFC 9207 Section 2.4 requires that issuer identifiers be compared using
// "simple string comparison" as defined in RFC 3986 Section 6.2.1, after
// proper normalization.
//
// RFC 3986 Section 6.2 specifies that:
// - Scheme and host components must be normalized to lowercase
// - Path components are case-sensitive and must NOT be lowercased
// - Default ports should be removed (443 for https, 80 for http)
// - Trailing slashes should be removed
// - Percent-encoding should be normalized
//
// This function performs the following normalizations:
// 1. Trims leading and trailing whitespace
// 2. Parses the URL to separate components
// 3. Lowercases the scheme (e.g., HTTPS → https)
// 4. Lowercases the host (e.g., Example.COM → example.com)
// 5. Preserves path case-sensitivity (e.g., /REALM/Team remains /REALM/Team)
// 6. Removes default ports (:443 for https, :80 for http)
// 7. Removes trailing slashes from the path
// 8. Normalizes percent-encoding in the path (uppercase hex, decode unreserved)
//
// Examples:
//
// normalizeIssuer("HTTPS://EXAMPLE.COM/REALM/Team/")
// → "https://example.com/REALM/Team"
// normalizeIssuer("https://example.com:443/path")
// → "https://example.com/path"
// normalizeIssuer(" https://Example.COM/ ")
// → "https://example.com"
//
// The function is idempotent: normalizing an already-normalized issuer
// returns the same value.
func NormalizeIssuer(issuer string) string {
// Trim whitespace
issuer = strings.TrimSpace(issuer)
if issuer == "" {
return ""
}
// Parse the URL
u, err := url.Parse(issuer)
if err != nil {
// If parsing fails, return the trimmed string as-is
return issuer
}
// Normalize scheme to lowercase
u.Scheme = strings.ToLower(u.Scheme)
// Normalize host to lowercase
u.Host = strings.ToLower(u.Host)
// Remove default ports
if (u.Scheme == "https" && strings.HasSuffix(u.Host, ":443")) ||
(u.Scheme == "http" && strings.HasSuffix(u.Host, ":80")) {
// Remove the default port
host := u.Host
if idx := strings.LastIndex(host, ":"); idx != -1 {
u.Host = host[:idx]
}
}
// Normalize path: resolve dot segments, preserve case, remove trailing slashes, and normalize percent-encoding
if u.Path != "" {
// First resolve dot segments (., .., etc.) per RFC 3986 Section 5.2.4
u.Path = path.Clean(u.Path)
// Remove trailing slashes from the decoded path
u.Path = strings.TrimRight(u.Path, "/")
// Get the escaped path (percent-encoded) to normalize it
escapedPath := u.EscapedPath()
// Normalize percent-encoding in the escaped path
normalizedPath := normalizePercentEncoding(escapedPath)
// Set both Path and RawPath to ensure proper encoding
u.RawPath = normalizedPath
// Decode the normalized path back to set u.Path correctly
if decodedPath, err := url.PathUnescape(normalizedPath); err == nil {
u.Path = decodedPath
}
}
// Reconstruct the URL
return u.String()
}
// normalizePercentEncoding normalizes percent-encoded characters in a path according to RFC 3986.
// It uppercases hexadecimal digits in percent-encoded triplets and decodes unreserved characters.
func normalizePercentEncoding(path string) string {
// isUnreserved returns true if the byte is an unreserved character per RFC 3986.
// Unreserved characters: A-Z, a-z, 0-9, hyphen (-), period (.), underscore (_), tilde (~)
isUnreserved := func(b byte) bool {
return (b >= 'A' && b <= 'Z') ||
(b >= 'a' && b <= 'z') ||
(b >= '0' && b <= '9') ||
b == '-' || b == '.' || b == '_' || b == '~'
}
var result strings.Builder
result.Grow(len(path))
for i := 0; i < len(path); i++ {
if path[i] == '%' && i+2 < len(path) {
// Extract the two hex digits
hex := path[i+1 : i+3]
// Try to parse the hex value (case-insensitive)
var b byte
if n, err := fmt.Sscanf(strings.ToLower(hex), "%02x", &b); err == nil && n == 1 {
// Check if this is an unreserved character that should be decoded
if isUnreserved(b) {
result.WriteByte(b)
i += 2
continue
}
// Otherwise, normalize to uppercase hex
result.WriteByte('%')
result.WriteString(strings.ToUpper(hex))
i += 2
continue
}
}
result.WriteByte(path[i])
}
return result.String()
}

219
helper/jwt/jwt_test.go Normal file
View file

@ -0,0 +1,219 @@
// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: BUSL-1.1
package jwt
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestNormalizeIssuer verifies RFC-compliant issuer normalization per RFC 9207 and RFC 3986.
// Only scheme and host are lowercased; path case is preserved.
func TestNormalizeIssuer(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
issuer string
expected string
}{
// Basic normalization
{
name: "scheme and host normalization",
issuer: "HTTPS://EXAMPLE.COM",
expected: "https://example.com",
},
{
name: "path case preservation",
issuer: "https://example.com/REALM/Team",
expected: "https://example.com/REALM/Team",
},
{
name: "scheme host and path normalization",
issuer: "HTTPS://EXAMPLE.COM/realm/TEAM",
expected: "https://example.com/realm/TEAM",
},
{
name: "trims whitespace and trailing slash",
issuer: " https://issuer.example.com/ ",
expected: "https://issuer.example.com",
},
{
name: "normalizes scheme and host but preserves path case",
issuer: "HTTPS://ISSUER.EXAMPLE.COM/REALM/Team/",
expected: "https://issuer.example.com/REALM/Team",
},
{
name: "trims multiple trailing slashes",
issuer: "https://issuer.example.com///",
expected: "https://issuer.example.com",
},
{
name: "trims trailing slashes from path",
issuer: "https://example.com/path///",
expected: "https://example.com/path",
},
{
name: "whitespace only becomes empty",
issuer: " \t\n ",
expected: "",
},
{
name: "empty string remains empty",
issuer: "",
expected: "",
},
// Port normalization
{
name: "removes default https port",
issuer: "https://example.com:443",
expected: "https://example.com",
},
{
name: "removes default https port with path",
issuer: "https://example.com:443/path",
expected: "https://example.com/path",
},
{
name: "removes default http port",
issuer: "http://example.com:80",
expected: "http://example.com",
},
{
name: "preserves non-default port",
issuer: "https://example.com:8443",
expected: "https://example.com:8443",
},
{
name: "preserves non-default port with path",
issuer: "https://example.com:8443/path",
expected: "https://example.com:8443/path",
},
// Percent-encoding normalization
{
name: "uppercases percent-encoded hex digits",
issuer: "https://example.com/%2a",
expected: "https://example.com/%2A",
},
{
name: "decodes unreserved characters - tilde",
issuer: "https://example.com/%7Euser",
expected: "https://example.com/~user",
},
{
name: "decodes unreserved characters - hyphen period underscore",
issuer: "https://example.com/%2D%2E%5F",
expected: "https://example.com/-._",
},
{
name: "preserves reserved percent-encoded characters",
issuer: "https://example.com/path%2Fto",
expected: "https://example.com/path%2Fto",
},
{
name: "alpha/numeric",
issuer: "https://example.com/%41%31",
expected: "https://example.com/A1",
},
{
name: "hyphen/period",
issuer: "https://example.com/%2D%2E",
expected: "https://example.com/-.",
},
{
name: "underscore/tilde",
issuer: "https://example.com/%5F%7E",
expected: "https://example.com/_~",
},
// Complex cases
{
name: "combined normalization with all features",
issuer: " HTTPS://EXAMPLE.COM:443/REALM/Team/ ",
expected: "https://example.com/REALM/Team",
},
{
name: "complex path with mixed case and trailing slashes",
issuer: "HTTPS://ISSUER.EXAMPLE.COM:443/realm/TEAM///",
expected: "https://issuer.example.com/realm/TEAM",
},
{
name: "path with percent-encoding and case preservation",
issuer: "https://example.com/Path%2FWith%7ESpecial",
expected: "https://example.com/Path%2FWith~Special",
},
// Root path
{
name: "root path with trailing slash",
issuer: "https://example.com/",
expected: "https://example.com",
},
{
name: "root path uppercase with trailing slash",
issuer: "HTTPS://EXAMPLE.COM/",
expected: "https://example.com",
},
// Path segment normalization
{
name: "single dot",
issuer: "http://example.com/a/./b",
expected: "http://example.com/a/b",
},
{
name: "double dot",
issuer: "http://example.com/a/b/../c",
expected: "http://example.com/a/c",
},
{
name: "leading dots",
issuer: "http://example.com/../a",
expected: "http://example.com/a",
},
{
name: "trailing dot",
issuer: "http://example.com/a/.",
expected: "http://example.com/a",
},
{
name: "complex path",
issuer: "http://example.com/a/b/c/./../../g",
expected: "http://example.com/a/g",
},
// Edge cases
{
name: "invalid URL returns trimmed input",
issuer: "not-a-url",
expected: "not-a-url",
},
{
name: "URL without scheme",
issuer: "example.com/path",
expected: "example.com/path",
},
// Idempotency
{
name: "already normalized remains unchanged",
issuer: "https://example.com/path",
expected: "https://example.com/path",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := NormalizeIssuer(tc.issuer)
require.Equal(t, tc.expected, result)
// Verify idempotency: normalizing twice should produce same result
result2 := NormalizeIssuer(result)
require.Equal(t, result, result2, "normalizeIssuer should be idempotent")
})
}
}