diff --git a/Dockerfile b/Dockerfile index 015c978c7f..8182b0239a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ ARG BASE_IMAGE=alpine -FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx +FROM --platform=$BUILDPLATFORM docker.io/tonistiigi/xx:1.9.0@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx -FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine3.22@sha256:727cfc3c40be55cd1bc9a4a059406b28a059857e3be752aa9d09531e12c20c56 AS builder +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26.4-alpine3.22@sha256:727cfc3c40be55cd1bc9a4a059406b28a059857e3be752aa9d09531e12c20c56 AS builder COPY --from=xx / / diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index bdd488d83d..4c12fc078d 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -37,6 +37,7 @@ import ( "github.com/dexidp/dex/api/v2" "github.com/dexidp/dex/pkg/featureflags" + dexRegexp "github.com/dexidp/dex/pkg/regexp" "github.com/dexidp/dex/server" "github.com/dexidp/dex/server/apiserver" "github.com/dexidp/dex/server/authflow" @@ -242,6 +243,20 @@ func runServe(options serveOptions) error { } c.StaticClients[i].Secret = os.Getenv(client.SecretEnv) } + if client.InsecureAllowRegexpRedirectURIs { + logger.Warn("using flag InsecureAllowRegexpRedirectURIs", "client", client.ID) + for _, uri := range client.RedirectURIs { + hasArbitraryWildcards, err := dexRegexp.HasArbitraryWildcard(uri) + if err != nil { + return fmt.Errorf("invalid config: RedirectURI %q is not a valid regexp expression: %w", uri, err) + } + + if !client.InsecureAllowWildcardRedirectURIs && hasArbitraryWildcards { + return fmt.Errorf("invalid config: InsecureAllowWildcardRedirectURIs is required when using any unrestricted wildcard") + } + + } + } logger.Info("config static client", "client_name", client.Name) } s = storage.WithStaticClients(s, c.StaticClients) diff --git a/pkg/regexp/regexp.go b/pkg/regexp/regexp.go new file mode 100644 index 0000000000..4694b1fcbc --- /dev/null +++ b/pkg/regexp/regexp.go @@ -0,0 +1,80 @@ +package regexp + +import ( + "regexp/syntax" + "slices" +) + +// isNegatedOrBroadClass determines whether a character class (e.g., [0-9], [^/], [a-z]) +// allows a character range wide enough to act as an unrestricted wildcard. +// +// In Go's regex AST (regexp/syntax), character classes (syntax.OpCharClass) are represented +// in the 'Rune' slice as a sequence of inclusive range pairs: [start1, end1, start2, end2, ...]. +// +// For example: +// - [0-9] is encoded as: ['0', '9'] -> 10 total characters +// - [a-zA-Z] is encoded as: ['A', 'Z', 'a', 'z'] -> 52 total characters +// - [^/] (negated class) expands into two vast ranges: +// [UnicodeMin, '/'-1, '/'+1, UnicodeMax] -> >1,100,000 characters +// +// If the total number of allowed characters exceeds the threshold (100), the class is +// considered either a negated class (like [^/]) or overly permissive, and thus classified +// as an unrestricted wildcard. +func isNegatedOrBroadClass(runes []rune) bool { + var totalChars int32 + + // Rune ranges are always stored in start/end pairs at even/odd indices: + // runes[i] = range start + // runes[i+1] = range end + for i := 0; i < len(runes); i += 2 { + totalChars += (runes[i+1] - runes[i] + 1) + } + + // Safety threshold: Allows common URL character sets like [a-zA-Z0-9_-] (~65 chars), + // while flagging negated classes or broad ranges that could match arbitrary subdomains. + return totalChars > 100 +} + +func inspectForWildcardSegments(node *syntax.Regexp) bool { + // Inspect for child elements first + // Any child element has an unrestricted wildcard + if slices.ContainsFunc(node.Sub, inspectForWildcardSegments) { + return true + } + + switch node.Op { + // Unrestricter char wildcard (AKA. the dot) + case syntax.OpAnyChar, syntax.OpAnyCharNotNL: + return true + + // Check for negated class chars with a wide allowed chars, marked as unrestricted wildcard. + // Ex: [^/], [^#], many others... + case syntax.OpCharClass: + if isNegatedOrBroadClass(node.Rune) { + return true + } + } + + return false +} + +// HasArbitraryWildcard parses the regex pattern to check if it contains unrestricted wildcards. +// +// An unrestricted wildcard includes constructs like '.', '.*', '.+', or broad/negated character +// classes like '[^/]+'. +// +// NOTE: Unescaped literal dots in domain names (e.g., "example.com" instead of "example\.com") +// are parsed as syntax.OpAnyCharNotNL and will be flagged as wildcards. This prevents subtle +// open-redirect vulnerabilities where "pr-123.example.com" could match "pr-123xexample.com" +func HasArbitraryWildcard(pattern string) (bool, error) { + ast, err := syntax.Parse(pattern, syntax.Perl) + if err != nil { + return false, err + } + + return inspectForWildcardSegments(ast), nil +} + +func SurroundRedirectURIRegexp(uri string) string { + return `\A(?:` + uri + `)\z` +} diff --git a/server/authflow/request.go b/server/authflow/request.go index 56dc927d51..263571923d 100644 --- a/server/authflow/request.go +++ b/server/authflow/request.go @@ -7,12 +7,14 @@ import ( "net" "net/http" "net/url" + "regexp" "slices" "strconv" "strings" "github.com/coreos/go-oidc/v3/oidc" + dexRegexp "github.com/dexidp/dex/pkg/regexp" conns "github.com/dexidp/dex/server/connectors" "github.com/dexidp/dex/server/oauth2" "github.com/dexidp/dex/server/signer" @@ -93,6 +95,16 @@ func validateRedirectURI(client storage.Client, redirectURI string) bool { return true } } + + // Check redirectURIs using regexp package if is allowed + if client.InsecureAllowRegexpRedirectURIs { + valid := validateRegexpRedirectURI(client.RedirectURIs, redirectURI, client.InsecureAllowWildcardRedirectURIs) + + if valid { + return true + } + } + // For non-public clients or when RedirectURIs is set, we allow only explicitly named RedirectURIs. if !client.Public || len(client.RedirectURIs) > 0 { return false @@ -126,6 +138,27 @@ func isHostLocal(host string) bool { return host == "localhost" || net.ParseIP(host).IsLoopback() } +func validateRegexpRedirectURI(redirectURIs []string, redirectURI string, allowWildcard bool) bool { + for _, uri := range redirectURIs { + // NOTE: This is also validated during server startup, but is safely skipped also during validation. + hasArbitraryWildcards, err := dexRegexp.HasArbitraryWildcard(uri) + if err != nil || (!allowWildcard && hasArbitraryWildcards) { + continue + } + + rgx, err := regexp.Compile(dexRegexp.SurroundRedirectURIRegexp(uri)) + if err != nil { + continue + } + + if rgx.MatchString(redirectURI) { + return true + } + } + + return false +} + func validateConnectorID(connectors []storage.Connector, connectorID string) bool { for _, c := range connectors { if c.ID == connectorID { diff --git a/server/authflow/request_test.go b/server/authflow/request_test.go index 3111e83b16..627c380f4c 100644 --- a/server/authflow/request_test.go +++ b/server/authflow/request_test.go @@ -169,6 +169,130 @@ func TestParseAuthorizationRequest(t *testing.T) { "scope": "openid email profile", }, }, + { + name: "regexp url", + clients: []storage.Client{ + { + ID: "bar", + InsecureAllowRegexpRedirectURIs: true, + RedirectURIs: []string{`https://pr-(\d+)\.example\.com`}, + }, + }, + supportedResponseTypes: []string{"code", "id_token", "token"}, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://pr-1010.example.com", + "response_type": "code", + "scope": "openid email profile", + }, + }, + { + name: "regexp url without flag", + clients: []storage.Client{ + { + ID: "bar", + InsecureAllowRegexpRedirectURIs: false, + RedirectURIs: []string{`https://pr-(\d+).example.com`}, + }, + }, + supportedResponseTypes: []string{"code", "id_token", "token"}, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://pr-1010.example.com", + "response_type": "code", + "scope": "openid email profile", + }, + expectedError: &displayedAuthErr{Status: http.StatusBadRequest}, + }, + { + name: "regexp url with malicious uri", + clients: []storage.Client{ + { + ID: "bar", + InsecureAllowRegexpRedirectURIs: true, + RedirectURIs: []string{`https://pr-(\d+).example.com`}, + }, + }, + supportedResponseTypes: []string{"code", "id_token", "token"}, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://pr-1010.example.com.attacker.xyz", + "response_type": "code", + "scope": "openid email profile", + }, + expectedError: &displayedAuthErr{Status: http.StatusBadRequest}, + }, + { + name: "wildcard url", + clients: []storage.Client{ + { + ID: "bar", + InsecureAllowRegexpRedirectURIs: true, + InsecureAllowWildcardRedirectURIs: true, + RedirectURIs: []string{`https?://.*`}, + }, + }, + supportedResponseTypes: []string{"code", "id_token", "token"}, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com", + "response_type": "code", + "scope": "openid email profile", + }, + }, + { + name: "wildcard url without flag", + clients: []storage.Client{ + { + ID: "bar", + InsecureAllowRegexpRedirectURIs: true, + RedirectURIs: []string{`https?://.*`}, + }, + }, + supportedResponseTypes: []string{"code", "id_token", "token"}, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com", + "response_type": "code", + "scope": "openid email profile", + }, + expectedError: &displayedAuthErr{Status: http.StatusBadRequest}, + }, + { + name: "wildcard url without flag alternate", + clients: []storage.Client{ + { + ID: "bar", + InsecureAllowRegexpRedirectURIs: true, + RedirectURIs: []string{`https?://[^/]`}, + }, + }, + supportedResponseTypes: []string{"code", "id_token", "token"}, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com", + "response_type": "code", + "scope": "openid email profile", + }, + expectedError: &displayedAuthErr{Status: http.StatusBadRequest}, + }, + { + name: "wildcard url", + clients: []storage.Client{ + { + ID: "bar", + InsecureAllowRegexpRedirectURIs: true, + RedirectURIs: []string{`http://127\.0\.0\.1:(\d+)`}, + }, + }, + supportedResponseTypes: []string{"code", "id_token", "token"}, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "http://127.0.0.1:12345", + "response_type": "code", + "scope": "openid email profile", + }, + }, { name: "choose second connector_id", clients: []storage.Client{ diff --git a/storage/storage.go b/storage/storage.go index 43d14b8d90..c72a7aef95 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -166,6 +166,17 @@ type Client struct { Secret string `json:"secret"` SecretEnv string `json:"secretEnv"` + // InsecureAllowRegexpRedirectURIs allows RedirectURIs entries to be + // interpreted as regular expressions for dynamic URIs. + // + // Note: The flag does not allow wildcard regexp like: ".*" or "https?://.*" or + // any ".*" in the string, unless using InsecureAllowWildcardRedirectURIs flag. + InsecureAllowRegexpRedirectURIs bool `json:"insecureAllowRegexpRedirectURIs"` + + // InsecureAllowWildcardRedirectURIs in use with InsecureAllowRegexpRedirectURIs + // allows to add wildcard regexp mainly for development purpose. + InsecureAllowWildcardRedirectURIs bool `json:"insecureAllowWildcardRedirectURIs"` + // A registered set of redirect URIs. When redirecting from dex to the client, the URI // requested to redirect to MUST match one of these values, unless the client is "public". RedirectURIs []string `json:"redirectURIs"`