From 88213d785ab2ee0249ec54a7b453c484c02a7c53 Mon Sep 17 00:00:00 2001 From: Yann Soubeyrand <8511577+yann-soubeyrand@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:10:49 +0200 Subject: [PATCH] fix(oidc): accept localhost redirect URIs without path nor port (#10836) # Which Problems Are Solved Some native OIDC applications use localhost without a path as redirect URI. Currently, setting `http://localhost` as a redirect URI leads to a compliance warning (`Redirect URIs must begin with your own protocol, http://127.0.0.1, http://[::1] or http://localhost.`), while `http://localhost/some/path` and `http://localhost:some-port` are accepted). # How the Problems Are Solved This PR adds additional checks to accept `http://localhost`, `http://127.0.0.1`, `http://[::1]` and `http://[0:0:0:0:0:0:0:1]` (their counterpart with port and with path were already accepted). --------- Co-authored-by: Marco Ardizzone --- internal/domain/application_oidc.go | 68 ++++++++++++++---------- internal/domain/application_oidc_test.go | 2 +- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 10a70a17762..00057a54e84 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -1,6 +1,8 @@ package domain import ( + "net/netip" + "net/url" "slices" "strings" "time" @@ -10,16 +12,9 @@ import ( ) const ( - http = "http://" - httpLocalhostWithPort = "http://localhost:" - httpLocalhostWithoutPort = "http://localhost/" - httpLoopbackV4WithPort = "http://127.0.0.1:" - httpLoopbackV4WithoutPort = "http://127.0.0.1/" - httpLoopbackV6WithPort = "http://[::1]:" - httpLoopbackV6WithoutPort = "http://[::1]/" - httpLoopbackV6LongWithPort = "http://[0:0:0:0:0:0:0:1]:" - httpLoopbackV6LongWithoutPort = "http://[0:0:0:0:0:0:0:1]/" - https = "https://" + httpScheme = "http://" + httpsScheme = "https://" + localhostHostname = "localhost" ) type OIDCApp struct { @@ -297,7 +292,7 @@ func CheckRedirectUrisCode(compliance *Compliance, appType *OIDCApplicationType, if urlsAreHttps(redirectUris) { return } - if urlContainsPrefix(redirectUris, http) { + if urlContainsPrefix(redirectUris, httpScheme) { if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") @@ -321,7 +316,7 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType *OIDCApplicationT compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } - if urlContainsPrefix(redirectUris, http) { + if urlContainsPrefix(redirectUris, httpScheme) { if appType != nil && *appType == OIDCApplicationTypeNative { if !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true @@ -342,7 +337,7 @@ func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType *OIDCAppli compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } - if urlContainsPrefix(redirectUris, http) { + if urlContainsPrefix(redirectUris, httpScheme) { if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") @@ -359,7 +354,7 @@ func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType *OIDCAppli func urlsAreHttps(uris []string) bool { for _, uri := range uris { - if !strings.HasPrefix(uri, https) { + if !strings.HasPrefix(uri, httpsScheme) { return false } } @@ -377,33 +372,52 @@ func urlContainsPrefix(uris []string, prefix string) bool { func containsCustom(uris []string) bool { for _, uri := range uris { - if !strings.HasPrefix(uri, http) && !strings.HasPrefix(uri, https) { + if !strings.HasPrefix(uri, httpScheme) && !strings.HasPrefix(uri, httpsScheme) { return true } } return false } +// onlyLocalhostIsHttp returns true if: +// +// - input string slice is empty +// - all parseable URIs with scheme `http` in the string slice are localhost/loopback URIs (in all possible forms) +// +// It will return false if: +// - any of the input URIs cannot be parsed +// - any of the parseable input URIs with scheme `http` is not localhost/loopback func onlyLocalhostIsHttp(uris []string) bool { for _, uri := range uris { - if strings.HasPrefix(uri, http) && !isHTTPLoopbackLocalhost(uri) { + url, err := url.ParseRequestURI(uri) + + if err != nil { + return false + } + + if url.Scheme == "http" { + hostname := url.Hostname() + + if hostname == localhostHostname { + continue + } + + address, err := netip.ParseAddr(hostname) + + if err != nil { + return false + } + + if address.IsLoopback() { + continue + } + return false } } return true } -func isHTTPLoopbackLocalhost(uri string) bool { - return strings.HasPrefix(uri, httpLocalhostWithoutPort) || - strings.HasPrefix(uri, httpLocalhostWithPort) || - strings.HasPrefix(uri, httpLoopbackV4WithoutPort) || - strings.HasPrefix(uri, httpLoopbackV4WithPort) || - strings.HasPrefix(uri, httpLoopbackV6WithoutPort) || - strings.HasPrefix(uri, httpLoopbackV6WithPort) || - strings.HasPrefix(uri, httpLoopbackV6LongWithoutPort) || - strings.HasPrefix(uri, httpLoopbackV6LongWithPort) -} - func OIDCOriginAllowList(redirectURIs, additionalOrigins []string) ([]string, error) { allowList := make([]string, 0) for _, redirect := range redirectURIs { diff --git a/internal/domain/application_oidc_test.go b/internal/domain/application_oidc_test.go index 4208917cdd2..143239eea78 100644 --- a/internal/domain/application_oidc_test.go +++ b/internal/domain/application_oidc_test.go @@ -489,7 +489,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { { name: "only http protocol, app type native, only localhost", args: args{ - redirectUris: []string{"http://localhost:8080"}, + redirectUris: []string{"http://localhost:8080", "http://localhost/", "http://localhost"}, appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{