Skip to content

Commit b3976e3

Browse files
authored
Merge commit from fork
This PR implements input validation for critical OIDC token fields (iss, sub, aud) according to RFC 8414 and OpenID Connect Core 1.0 specifications. The implementation addresses security vulnerabilities including endpoint discovery manipulation and injection attacks while maintaining compatibility with legitimate identity providers. Add the following files - pkg/octosts/validation.go - Core validation functions - pkg/octosts/validation_test.go - Test coverage (55+ test cases) Modified Files - pkg/octosts/octosts.go - Added issuer validation in token exchange - pkg/octosts/trust_policy.go - Added validation for all critical token fields Prevent to redirect to an invalid URL during OIDC discovery by adding a function to: - validate the redirect destination using `oidcvalidate.IsValidIssuer()` - reject redirects to URLs that don't meet our security requirements - return an error that prevents the redirect from being followed Signed-off-by: Maxime Gréau <[email protected]>
1 parent d55477b commit b3976e3

File tree

5 files changed

+772
-0
lines changed

5 files changed

+772
-0
lines changed

pkg/octosts/octosts.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
apiauth "chainguard.dev/sdk/auth"
3232
pboidc "chainguard.dev/sdk/proto/platform/oidc/v1"
3333
"github.com/chainguard-dev/clog"
34+
"github.com/octo-sts/app/pkg/oidcvalidate"
3435
"github.com/octo-sts/app/pkg/provider"
3536
)
3637

@@ -113,6 +114,11 @@ func (s *sts) Exchange(ctx context.Context, request *pboidc.ExchangeRequest) (_
113114
return nil, status.Errorf(codes.InvalidArgument, "invalid bearer token: %v", err)
114115
}
115116

117+
// Validate issuer format
118+
if !oidcvalidate.IsValidIssuer(issuer) {
119+
return nil, status.Error(codes.InvalidArgument, "invalid issuer format")
120+
}
121+
116122
// Fetch the provider from the cache or create a new one and add to the cache
117123
p, err := provider.Get(ctx, issuer)
118124
if err != nil {

pkg/octosts/trust_policy.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/coreos/go-oidc/v3/oidc"
1313
"github.com/google/go-github/v72/github"
14+
"github.com/octo-sts/app/pkg/oidcvalidate"
1415
"google.golang.org/grpc/codes"
1516
"google.golang.org/grpc/status"
1617
)
@@ -115,6 +116,19 @@ func (tp *TrustPolicy) CheckToken(token *oidc.IDToken, domain string) (Actor, er
115116
return act, status.Errorf(codes.Internal, "trust policy: not compiled")
116117
}
117118

119+
// Validate critical token fields
120+
if !oidcvalidate.IsValidIssuer(token.Issuer) {
121+
return act, status.Errorf(codes.InvalidArgument, "invalid issuer in token")
122+
}
123+
if !oidcvalidate.IsValidSubject(token.Subject) {
124+
return act, status.Errorf(codes.InvalidArgument, "invalid subject in token")
125+
}
126+
for _, aud := range token.Audience {
127+
if !oidcvalidate.IsValidAudience(aud) {
128+
return act, status.Errorf(codes.InvalidArgument, "invalid audience in token")
129+
}
130+
}
131+
118132
// Check the issuer.
119133
switch {
120134
case tp.issuerPattern != nil:

pkg/oidcvalidate/validate.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright 2025 Chainguard, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package oidcvalidate
5+
6+
import (
7+
"net/url"
8+
"regexp"
9+
"strings"
10+
"unicode"
11+
"unicode/utf8"
12+
)
13+
14+
const (
15+
// controlCharsAndWhitespace contains ASCII control characters (0x00-0x1f) plus whitespace
16+
controlCharsAndWhitespace = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f \t\n\r"
17+
)
18+
19+
// IsValidIssuer validates an OIDC issuer according to RFC 8414 and OpenID Connect Core 1.0:
20+
// - Must use HTTPS scheme (except localhost for testing)
21+
// - Must be a valid URL without query string or fragment
22+
// - Must not end with a slash
23+
// - Must have a valid hostname
24+
// - Length constraints for security
25+
func IsValidIssuer(iss string) bool {
26+
// Basic length check
27+
if len(iss) == 0 || utf8.RuneCountInString(iss) > 255 {
28+
return false
29+
}
30+
31+
// Parse as URL
32+
parsedURL, err := url.Parse(iss)
33+
if err != nil {
34+
return false
35+
}
36+
37+
// Must use HTTPS (allow HTTP only for localhost/127.0.0.1 for development/testing environments)
38+
if parsedURL.Scheme != "https" {
39+
if parsedURL.Scheme == "http" {
40+
host := parsedURL.Hostname()
41+
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
42+
return false
43+
}
44+
} else {
45+
// Reject any scheme that is not HTTPS or HTTP
46+
return false
47+
}
48+
}
49+
50+
// Must not contain query string or fragment (RFC 8414)
51+
// Check both parsed components and raw string for fragments/queries
52+
if parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
53+
return false
54+
}
55+
if strings.ContainsAny(iss, "?#") {
56+
return false
57+
}
58+
59+
// Must have a valid hostname
60+
if parsedURL.Host == "" {
61+
return false
62+
}
63+
64+
// Reject URLs with userinfo (user:pass@host)
65+
if parsedURL.User != nil {
66+
return false
67+
}
68+
69+
// Comprehensive hostname validation
70+
if !isValidHostname(parsedURL.Hostname()) {
71+
return false
72+
}
73+
74+
// Path validation - if present, must be valid
75+
if parsedURL.Path != "" {
76+
// Reject paths with .. or other suspicious patterns
77+
if strings.Contains(parsedURL.Path, "..") {
78+
return false
79+
}
80+
// Must start with / if path is present
81+
if !strings.HasPrefix(parsedURL.Path, "/") {
82+
return false
83+
}
84+
// Reject multiple consecutive slashes (e.g., //, ///)
85+
if strings.Contains(parsedURL.Path, "//") {
86+
return false
87+
}
88+
// Reject multiple consecutive tildes (e.g., ~~, ~~~)
89+
if strings.Contains(parsedURL.Path, "~~") {
90+
return false
91+
}
92+
// Reject paths ending with tilde (could indicate backup files)
93+
if strings.HasSuffix(parsedURL.Path, "~") {
94+
return false
95+
}
96+
// Strict path character validation - only allow safe characters
97+
// Allow: letters, digits, hyphens, dots, underscores, tildes, forward slashes
98+
pathRe := regexp.MustCompile(`^[a-zA-Z0-9\-._~/]+$`)
99+
if !pathRe.MatchString(parsedURL.Path) {
100+
return false
101+
}
102+
// Additional validation: each path segment should be reasonable
103+
segments := strings.Split(parsedURL.Path, "/")
104+
for _, segment := range segments {
105+
if segment == "" {
106+
continue // Skip empty segments (like the first one after leading /)
107+
}
108+
// Reject segments that are just dots or tildes
109+
if segment == "." || segment == ".." || segment == "~" {
110+
return false
111+
}
112+
// Reject very long path segments to prevent buffer overflows, path traversal,
113+
// and resource exhaustion. RFC 3986 sets no explicit limit, but a 150-character
114+
// cap is reasonable for legitimate paths in most apps.
115+
if len(segment) > 150 {
116+
return false
117+
}
118+
}
119+
}
120+
121+
return true
122+
}
123+
124+
// IsValidSubject validates a subject identifier according to OpenID Connect Core 1.0:
125+
// - Must not be empty (REQUIRED)
126+
// - Must be a string with maximum length of 255 ASCII characters
127+
// - Must not contain whitespace or control characters
128+
// - Case sensitive string comparison
129+
func IsValidSubject(sub string) bool {
130+
// Must not be empty (OIDC Core requirement)
131+
if sub == "" {
132+
return false
133+
}
134+
135+
// Length validation - OIDC recommends max 255 ASCII characters
136+
if utf8.RuneCountInString(sub) > 255 {
137+
return false
138+
}
139+
140+
// Must not contain control characters, whitespace, or newlines
141+
// These could interfere with logging, storage, or protocol handling
142+
if strings.ContainsAny(sub, controlCharsAndWhitespace) {
143+
return false
144+
}
145+
146+
// Reject obviously problematic characters that could cause issues
147+
// in various contexts (JSON, XML, SQL, shell, etc.)
148+
// Allow: | : / @ - (commonly used by Auth0, GitHub Actions, etc.)
149+
if strings.ContainsAny(sub, "\"'`\\<>;&$(){}[]") {
150+
return false
151+
}
152+
153+
// The subject MUST be valid UTF-8 (already ensured by Go string type)
154+
// and should be printable characters only
155+
for _, r := range sub {
156+
// Reject non-printable characters (except those already caught above)
157+
if !unicode.IsPrint(r) {
158+
return false
159+
}
160+
}
161+
162+
return true
163+
}
164+
165+
// IsValidAudience validates an audience identifier according to OpenID Connect Core 1.0:
166+
// - Must not be empty (audience is REQUIRED)
167+
// - Should be a URI or an arbitrary string that uniquely identifies the audience
168+
// - Case sensitive string comparison
169+
// - Maximum length of 255 characters for security
170+
// - Must not contain control characters or injection-prone characters
171+
func IsValidAudience(aud string) bool {
172+
// Must not be empty (OIDC requirement)
173+
if aud == "" {
174+
return false
175+
}
176+
177+
// Length validation for security
178+
if utf8.RuneCountInString(aud) > 255 {
179+
return false
180+
}
181+
182+
// Must not contain control characters, whitespace that could cause parsing issues
183+
if strings.ContainsAny(aud, controlCharsAndWhitespace) {
184+
return false
185+
}
186+
187+
// Reject characters that could cause injection issues or confusion
188+
if strings.ContainsAny(aud, "\"'`\\<>;|&$(){}[]@") {
189+
return false
190+
}
191+
192+
// Audience should be printable characters only
193+
for _, r := range aud {
194+
if !unicode.IsPrint(r) {
195+
return false
196+
}
197+
}
198+
199+
return true
200+
}
201+
202+
// isValidHostname validates hostnames against homograph attacks and Unicode confusion
203+
func isValidHostname(hostname string) bool {
204+
// Empty hostname is invalid
205+
if hostname == "" {
206+
return false
207+
}
208+
209+
// Reject control characters, whitespace, tabs, newlines
210+
if strings.ContainsAny(hostname, controlCharsAndWhitespace) {
211+
return false
212+
}
213+
214+
// Reject Unicode characters to prevent homograph attacks
215+
// Examples: exämple.com (ä), eⓍample.com (Ⓧ), еxample.com (Cyrillic е)
216+
for _, r := range hostname {
217+
if r > 127 {
218+
return false
219+
}
220+
}
221+
222+
return true
223+
}

0 commit comments

Comments
 (0)