From cc97767a8af2c7c4a1f01540472439ce9ccef5f1 Mon Sep 17 00:00:00 2001 From: Sebastian Gaviria Tangarife Date: Sat, 25 Jul 2026 19:58:45 -0500 Subject: [PATCH 1/6] feat: Allowing on RedirectURIs the usage of regexp due to issue #448. Adding new flags for conditional logic and allowing back compatibility of the feature Signed-off-by: Sebastian Gaviria Tangarife --- Dockerfile | 4 +- cmd/dex/serve.go | 13 ++++++ server/authflow/request.go | 30 ++++++++++++++ server/authflow/request_test.go | 71 +++++++++++++++++++++++++++++++++ storage/storage.go | 11 +++++ 5 files changed, 127 insertions(+), 2 deletions(-) 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..66b363ab04 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -14,6 +14,7 @@ import ( "os" "os/signal" "path/filepath" + "regexp" "runtime" "strings" "sync/atomic" @@ -242,6 +243,18 @@ func runServe(options serveOptions) error { } c.StaticClients[i].Secret = os.Getenv(client.SecretEnv) } + if client.InsecureAllowRegexpRedirectURIs { + for _, uri := range client.RedirectURIs { + if client.InsecureAllowWildcardRedirectURIs && strings.Contains(uri, ".*") { + return fmt.Errorf("invalid config: InsecureAllowWildcardRedirectURIs is required when using \".*\"") + } + + _, err := regexp.Compile(uri) + if err != nil { + return fmt.Errorf("invalid config: RedirectURI %q is not a valid regexp expression", uri) + } + } + } logger.Info("config static client", "client_name", client.Name) } s = storage.WithStaticClients(s, c.StaticClients) diff --git a/server/authflow/request.go b/server/authflow/request.go index 56dc927d51..09f83aee7d 100644 --- a/server/authflow/request.go +++ b/server/authflow/request.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/url" + "regexp" "slices" "strconv" "strings" @@ -93,6 +94,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 +137,25 @@ 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 { + if !allowWildcard && strings.Contains(uri, ".*") { + continue + } + + rgx, err := regexp.Compile(uri) + if err != nil { + continue + } + + if rgx.Match([]byte(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..7e10992fd8 100644 --- a/server/authflow/request_test.go +++ b/server/authflow/request_test.go @@ -169,6 +169,77 @@ 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: "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: "choose second connector_id", clients: []storage.Client{ diff --git a/storage/storage.go b/storage/storage.go index 43d14b8d90..c53d4a740a 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 is an additiona flag allowing, add to + // RedirectURIs regexp 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"` From 5cfed810502dfb605b18abc4e8989cf1e7930124 Mon Sep 17 00:00:00 2001 From: Sebastian Gaviria Tangarife Date: Sat, 25 Jul 2026 20:06:07 -0500 Subject: [PATCH 2/6] chore: Add surrond regexp with delimiters preventing regexp partial match abuse Signed-off-by: Sebastian Gaviria Tangarife --- server/authflow/request.go | 15 ++++++++++++++- server/authflow/request_test.go | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/server/authflow/request.go b/server/authflow/request.go index 09f83aee7d..288ac655f8 100644 --- a/server/authflow/request.go +++ b/server/authflow/request.go @@ -143,7 +143,7 @@ func validateRegexpRedirectURI(redirectURIs []string, redirectURI string, allowW continue } - rgx, err := regexp.Compile(uri) + rgx, err := regexp.Compile(surroundRedirectURIRegexp(uri)) if err != nil { continue } @@ -156,6 +156,19 @@ func validateRegexpRedirectURI(redirectURIs []string, redirectURI string, allowW return false } +func surroundRedirectURIRegexp(uri string) (result string) { + result = uri + if result[0] != '^' { + result = "^" + result + } + + if result[len(result)-1] != '$' { + result = result + "$" + } + + return +} + 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 7e10992fd8..b62f3126e6 100644 --- a/server/authflow/request_test.go +++ b/server/authflow/request_test.go @@ -204,6 +204,24 @@ func TestParseAuthorizationRequest(t *testing.T) { }, 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{ From 6dc0f83fbcb678125db4a601198c8cf57f5fce6f Mon Sep 17 00:00:00 2001 From: Sebastian Gaviria Tangarife Date: Sat, 25 Jul 2026 20:08:32 -0500 Subject: [PATCH 3/6] fix: on typo for wildcard validation Signed-off-by: Sebastian Gaviria Tangarife --- cmd/dex/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 66b363ab04..fedfb74a19 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -245,7 +245,7 @@ func runServe(options serveOptions) error { } if client.InsecureAllowRegexpRedirectURIs { for _, uri := range client.RedirectURIs { - if client.InsecureAllowWildcardRedirectURIs && strings.Contains(uri, ".*") { + if !client.InsecureAllowWildcardRedirectURIs && strings.Contains(uri, ".*") { return fmt.Errorf("invalid config: InsecureAllowWildcardRedirectURIs is required when using \".*\"") } From eff4f06bf0c046fa8842c972c61b771f62b7d32b Mon Sep 17 00:00:00 2001 From: Sebastian Gaviria Tangarife Date: Fri, 28 Aug 2026 20:24:59 -0500 Subject: [PATCH 4/6] fix by copilot: amend flags docs on Client struct Signed-off-by: Sebastian Gaviria Tangarife --- storage/storage.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/storage/storage.go b/storage/storage.go index c53d4a740a..c72a7aef95 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -166,8 +166,8 @@ type Client struct { Secret string `json:"secret"` SecretEnv string `json:"secretEnv"` - // InsecureAllowRegexpRedirectURIs is an additiona flag allowing, add to - // RedirectURIs regexp expressions for dynamic URIs. + // 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. From b64679ef0e0c708a82511ad85edb5b146bc657b8 Mon Sep 17 00:00:00 2001 From: Sebastian Gaviria Tangarife Date: Fri, 28 Aug 2026 20:25:31 -0500 Subject: [PATCH 5/6] feat: Add fully featured wildcard detection on redirect uris Signed-off-by: Sebastian Gaviria Tangarife --- cmd/dex/serve.go | 14 +++--- pkg/regexp/regexp.go | 76 +++++++++++++++++++++++++++++++++ server/authflow/request.go | 7 ++- server/authflow/request_test.go | 37 +++++++++++++++- 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 pkg/regexp/regexp.go diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index fedfb74a19..4c12fc078d 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -14,7 +14,6 @@ import ( "os" "os/signal" "path/filepath" - "regexp" "runtime" "strings" "sync/atomic" @@ -38,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" @@ -244,15 +244,17 @@ 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 { - if !client.InsecureAllowWildcardRedirectURIs && strings.Contains(uri, ".*") { - return fmt.Errorf("invalid config: InsecureAllowWildcardRedirectURIs is required when using \".*\"") + hasArbitraryWildcards, err := dexRegexp.HasArbitraryWildcard(uri) + if err != nil { + return fmt.Errorf("invalid config: RedirectURI %q is not a valid regexp expression: %w", uri, err) } - _, err := regexp.Compile(uri) - if err != nil { - return fmt.Errorf("invalid config: RedirectURI %q is not a valid regexp expression", uri) + 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) diff --git a/pkg/regexp/regexp.go b/pkg/regexp/regexp.go new file mode 100644 index 0000000000..d21d67d2a2 --- /dev/null +++ b/pkg/regexp/regexp.go @@ -0,0 +1,76 @@ +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 +} diff --git a/server/authflow/request.go b/server/authflow/request.go index 288ac655f8..9eba904906 100644 --- a/server/authflow/request.go +++ b/server/authflow/request.go @@ -14,6 +14,7 @@ import ( "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" @@ -139,7 +140,9 @@ func isHostLocal(host string) bool { func validateRegexpRedirectURI(redirectURIs []string, redirectURI string, allowWildcard bool) bool { for _, uri := range redirectURIs { - if !allowWildcard && strings.Contains(uri, ".*") { + // 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 } @@ -148,7 +151,7 @@ func validateRegexpRedirectURI(redirectURIs []string, redirectURI string, allowW continue } - if rgx.Match([]byte(redirectURI)) { + if rgx.MatchString(redirectURI) { return true } } diff --git a/server/authflow/request_test.go b/server/authflow/request_test.go index b62f3126e6..627c380f4c 100644 --- a/server/authflow/request_test.go +++ b/server/authflow/request_test.go @@ -175,7 +175,7 @@ func TestParseAuthorizationRequest(t *testing.T) { { ID: "bar", InsecureAllowRegexpRedirectURIs: true, - RedirectURIs: []string{`https://pr-(\d+).example.com`}, + RedirectURIs: []string{`https://pr-(\d+)\.example\.com`}, }, }, supportedResponseTypes: []string{"code", "id_token", "token"}, @@ -258,6 +258,41 @@ func TestParseAuthorizationRequest(t *testing.T) { }, 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{ From c4c5183c39ddf7be39dda9e6469e13e7f8d65d2e Mon Sep 17 00:00:00 2001 From: Sebastian Gaviria Tangarife Date: Fri, 28 Aug 2026 20:32:32 -0500 Subject: [PATCH 6/6] fix: over regex surrounding (by copilot code review) Signed-off-by: Sebastian Gaviria Tangarife --- pkg/regexp/regexp.go | 4 ++++ server/authflow/request.go | 15 +-------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/pkg/regexp/regexp.go b/pkg/regexp/regexp.go index d21d67d2a2..4694b1fcbc 100644 --- a/pkg/regexp/regexp.go +++ b/pkg/regexp/regexp.go @@ -74,3 +74,7 @@ func HasArbitraryWildcard(pattern string) (bool, error) { 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 9eba904906..263571923d 100644 --- a/server/authflow/request.go +++ b/server/authflow/request.go @@ -146,7 +146,7 @@ func validateRegexpRedirectURI(redirectURIs []string, redirectURI string, allowW continue } - rgx, err := regexp.Compile(surroundRedirectURIRegexp(uri)) + rgx, err := regexp.Compile(dexRegexp.SurroundRedirectURIRegexp(uri)) if err != nil { continue } @@ -159,19 +159,6 @@ func validateRegexpRedirectURI(redirectURIs []string, redirectURI string, allowW return false } -func surroundRedirectURIRegexp(uri string) (result string) { - result = uri - if result[0] != '^' { - result = "^" + result - } - - if result[len(result)-1] != '$' { - result = result + "$" - } - - return -} - func validateConnectorID(connectors []storage.Connector, connectorID string) bool { for _, c := range connectors { if c.ID == connectorID {