Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 / /

Expand Down
15 changes: 15 additions & 0 deletions cmd/dex/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
80 changes: 80 additions & 0 deletions pkg/regexp/regexp.go
Original file line number Diff line number Diff line change
@@ -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`
}
33 changes: 33 additions & 0 deletions server/authflow/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
124 changes: 124 additions & 0 deletions server/authflow/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
11 changes: 11 additions & 0 deletions storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down