mirror of
https://github.com/hashicorp/vault.git
synced 2026-07-16 12:26:02 -04:00
* 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:
parent
78357295f8
commit
ab0fa8cc3e
2 changed files with 363 additions and 0 deletions
144
helper/jwt/jwt.go
Normal file
144
helper/jwt/jwt.go
Normal 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
219
helper/jwt/jwt_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue