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 <marco@zitadel.com>
This commit is contained in:
Yann Soubeyrand
2025-10-21 12:10:49 +02:00
committed by GitHub
parent ebc6c503a3
commit 88213d785a
2 changed files with 42 additions and 28 deletions

View File

@@ -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 {

View File

@@ -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{