155 lines
5.2 KiB
Go
155 lines
5.2 KiB
Go
package jwtverify
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp/syntax"
|
|
"strings"
|
|
)
|
|
|
|
// extractTemplatePlaceholders returns the names of all {{name}} placeholders in s.
|
|
func extractTemplatePlaceholders(s string) []string {
|
|
var result []string
|
|
for {
|
|
start := strings.Index(s, "{{")
|
|
if start == -1 {
|
|
break
|
|
}
|
|
rest := s[start+2:]
|
|
end := strings.Index(rest, "}}")
|
|
if end == -1 {
|
|
break
|
|
}
|
|
result = append(result, rest[:end])
|
|
s = rest[end+2:]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// findNamedGroupSubTrees parses a regex pattern and returns a map from each named
|
|
// capture group to its parsed sub-expression AST. We work with AST nodes directly
|
|
// to avoid round-tripping through String() which can introduce optimizations like
|
|
// character classes (e.g., "test1|test2" → "test[12]") that change the structure.
|
|
func findNamedGroupSubTrees(pattern string) (map[string]*syntax.Regexp, error) {
|
|
re, err := syntax.Parse(pattern, syntax.Perl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
groups := make(map[string]*syntax.Regexp)
|
|
var walk func(re *syntax.Regexp)
|
|
walk = func(re *syntax.Regexp) {
|
|
if re.Op == syntax.OpCapture && re.Name != "" {
|
|
if len(re.Sub) > 0 {
|
|
groups[re.Name] = re.Sub[0]
|
|
}
|
|
}
|
|
for _, sub := range re.Sub {
|
|
walk(sub)
|
|
}
|
|
}
|
|
walk(re)
|
|
return groups, nil
|
|
}
|
|
|
|
// isLiteralTree returns true if the regexp AST can only match a finite set of fixed
|
|
// strings — no wildcards (dot), repetitions (star, plus, quest), or open-ended
|
|
// constructs. The following ops are accepted:
|
|
// - OpLiteral, OpEmptyMatch — fixed content.
|
|
// - OpConcat, OpCapture, OpAlternate — structural; safe if all children are safe.
|
|
// - OpCharClass — matches exactly one rune from a finite set. The Go regexp parser
|
|
// produces these when factoring common prefixes from alternations of literals
|
|
// (e.g., "test1|test2" → Concat(Literal("test"), CharClass("12"))).
|
|
func isLiteralTree(re *syntax.Regexp) bool {
|
|
switch re.Op {
|
|
case syntax.OpLiteral, syntax.OpEmptyMatch, syntax.OpCharClass:
|
|
return true
|
|
case syntax.OpConcat, syntax.OpCapture, syntax.OpAlternate:
|
|
for _, sub := range re.Sub {
|
|
if !isLiteralTree(sub) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// rejectCharClassSyntax checks whether a regex pattern contains character class syntax
|
|
// that is not allowed in issuer/audience regexes when JWKS endpoint templates are used.
|
|
// This catches both bracket syntax ([...]) and shorthand classes (\d, \w, \s, \D, \W, \S)
|
|
// as well as Unicode property classes (\p, \P).
|
|
func rejectCharClassSyntax(pattern string) error {
|
|
for i := 0; i < len(pattern); i++ {
|
|
switch pattern[i] {
|
|
case '[':
|
|
return fmt.Errorf("contains character class syntax which is not allowed when JWKS endpoint URL template is used")
|
|
case '\\':
|
|
if i+1 < len(pattern) {
|
|
switch pattern[i+1] {
|
|
case 'd', 'D', 'w', 'W', 's', 'S', 'p', 'P':
|
|
return fmt.Errorf("contains character class shorthand (\\%c) which is not allowed when JWKS endpoint URL template is used", pattern[i+1])
|
|
}
|
|
i++ // skip escaped char
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateJWKSEndpointSafety checks that regex named groups used as JWKS endpoint
|
|
// template placeholders are explicit alternations of fixed strings (e.g.,
|
|
// "value1|value2|value3"). This is required regardless of URL position because
|
|
// template values come from unverified JWT claims — a permissive pattern would let
|
|
// an attacker substitute an arbitrary value and redirect JWKS key fetches to a server
|
|
// they control (whether via host, subdomain on a shared cloud platform, or path on
|
|
// a shared auth service). Runtime path.Clean protection in the JWKS manager provides
|
|
// additional defense-in-depth against path traversal.
|
|
func validateJWKSEndpointSafety(endpoint, issuerRegex, audienceRegex string) error {
|
|
placeholders := extractTemplatePlaceholders(endpoint)
|
|
if len(placeholders) == 0 {
|
|
return nil
|
|
}
|
|
placeholderSet := make(map[string]struct{}, len(placeholders))
|
|
for _, p := range placeholders {
|
|
placeholderSet[p] = struct{}{}
|
|
}
|
|
for _, regexConfig := range []struct {
|
|
name string
|
|
pattern string
|
|
}{
|
|
{"issuer_regex", issuerRegex},
|
|
{"audience_regex", audienceRegex},
|
|
} {
|
|
if regexConfig.pattern == "" {
|
|
continue
|
|
}
|
|
// Reject character classes in the source pattern. The regexp parser
|
|
// may produce OpCharClass from alternation optimization (e.g., "test1|test2"
|
|
// → CharClass("12")), but user-written character classes must not be allowed.
|
|
// This covers bracket syntax ([...]) and shorthand classes (\d, \w, \s, etc.).
|
|
if err := rejectCharClassSyntax(regexConfig.pattern); err != nil {
|
|
return fmt.Errorf(
|
|
"%s %w — use an explicit list of allowed values "+
|
|
"(e.g., value1|value2|value3)",
|
|
regexConfig.name, err)
|
|
}
|
|
groups, err := findNamedGroupSubTrees(regexConfig.pattern)
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing %s for JWKS safety check: %w", regexConfig.name, err)
|
|
}
|
|
for groupName, subTree := range groups {
|
|
if _, ok := placeholderSet[groupName]; !ok {
|
|
continue
|
|
}
|
|
if !isLiteralTree(subTree) {
|
|
return fmt.Errorf(
|
|
"%s named group %q is used in JWKS endpoint URL template — "+
|
|
"only an explicit list of allowed values is permitted "+
|
|
"(e.g., value1|value2|value3)",
|
|
regexConfig.name, groupName)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|