diff --git a/internal/api/http/middleware/origin_interceptor.go b/internal/api/http/middleware/origin_interceptor.go index 8528fb2a1a..66271ec705 100644 --- a/internal/api/http/middleware/origin_interceptor.go +++ b/internal/api/http/middleware/origin_interceptor.go @@ -30,11 +30,17 @@ func WithOrigin(enforceHttps bool, http1Header, http2Header string, instanceHost } func composeDomainContext(r *http.Request, enforceHttps bool, instanceDomainHeaders, publicDomainHeaders []string) (_ *http_util.DomainCtx) { - instanceHost, instanceProto := hostFromRequest(r, instanceDomainHeaders) - publicHost, publicProto := hostFromRequest(r, publicDomainHeaders) + instanceHost, instanceProto, _ := hostFromRequest(r, instanceDomainHeaders) + publicHost, publicProto, rawPublicHost := hostFromRequest(r, publicDomainHeaders) if instanceHost == "" { instanceHost = r.Host } + // An invalid public host must not be dropped: an empty public host falls back to the instance host + // and skips the trusted domain check, which would allow building links (e.g. password reset) with an untrusted host. + // Keep the raw value so the trusted domain check rejects the request. + if publicHost == "" && rawPublicHost != "" { + publicHost = rawPublicHost + } return http_util.NewDomainCtx(instanceHost, publicHost, protocolFromRequest(instanceProto, publicProto, enforceHttps)) } @@ -51,7 +57,7 @@ func protocolFromRequest(instanceProto, publicProto string, enforceHttps bool) s return "http" } -func hostFromRequest(r *http.Request, headers []string) (host, proto string) { +func hostFromRequest(r *http.Request, headers []string) (host, proto, rawHost string) { var hostFromHeader, protoFromHeader string for _, header := range headers { switch http.CanonicalHeaderKey(header) { @@ -66,6 +72,9 @@ func hostFromRequest(r *http.Request, headers []string) (host, proto string) { default: hostFromHeader = r.Header.Get(header) } + if rawHost == "" { + rawHost = hostFromHeader + } if host == "" { host = sanitizeHost(hostFromHeader) } @@ -73,7 +82,7 @@ func hostFromRequest(r *http.Request, headers []string) (host, proto string) { proto = protoFromHeader } } - return host, proto + return host, proto, rawHost } func sanitizeHost(rawHost string) (host string) { diff --git a/internal/api/http/middleware/origin_interceptor_test.go b/internal/api/http/middleware/origin_interceptor_test.go index 9dcc9b6392..eacf41e415 100644 --- a/internal/api/http/middleware/origin_interceptor_test.go +++ b/internal/api/http/middleware/origin_interceptor_test.go @@ -189,6 +189,65 @@ func Test_composeOrigin(t *testing.T) { InstanceHost: "forwarded.host", Protocol: "https", }, + }, { + name: "public host", + args: args{ + h: http.Header{ + "X-Zitadel-Public-Host": []string{"public.host"}, + }, + }, + want: &http_util.DomainCtx{ + InstanceHost: "host.header", + PublicHost: "public.host", + Protocol: "http", + }, + }, { + name: "public host with port", + args: args{ + h: http.Header{ + "X-Zitadel-Public-Host": []string{"public.host:8080"}, + }, + }, + want: &http_util.DomainCtx{ + InstanceHost: "host.header", + PublicHost: "public.host:8080", + Protocol: "http", + }, + }, { + name: "invalid public host (non-numeric port) is kept to be rejected by the trusted domain check", + args: args{ + h: http.Header{ + "X-Zitadel-Public-Host": []string{"evil.com:443/"}, + }, + }, + want: &http_util.DomainCtx{ + InstanceHost: "host.header", + PublicHost: "evil.com:443/", + Protocol: "http", + }, + }, { + name: "invalid public host (userinfo) is kept to be rejected by the trusted domain check", + args: args{ + h: http.Header{ + "X-Zitadel-Public-Host": []string{"a:b@evil.com"}, + }, + }, + want: &http_util.DomainCtx{ + InstanceHost: "host.header", + PublicHost: "a:b@evil.com", + Protocol: "http", + }, + }, { + name: "invalid instance host is ignored", + args: args{ + h: http.Header{ + "X-Forwarded-Host": []string{"localhost:@attacker.com"}, + }, + }, + want: &http_util.DomainCtx{ + InstanceHost: "host.header", + Protocol: "http", + }, }, } for _, tt := range tests { diff --git a/internal/query/instance_domain_check_test.go b/internal/query/instance_domain_check_test.go new file mode 100644 index 0000000000..1f94cc6dd6 --- /dev/null +++ b/internal/query/instance_domain_check_test.go @@ -0,0 +1,65 @@ +package query + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func Test_authzInstance_checkDomain(t *testing.T) { + instance := &authzInstance{ + TrustedDomains: []string{"trusted.domain"}, + } + tests := []struct { + name string + domain *http_util.DomainCtx + wantErr bool + }{ + { + name: "no public host", + domain: &http_util.DomainCtx{InstanceHost: "instance.domain"}, + }, + { + name: "public host equals instance host", + domain: &http_util.DomainCtx{InstanceHost: "instance.domain", PublicHost: "instance.domain"}, + }, + { + name: "trusted public host", + domain: &http_util.DomainCtx{InstanceHost: "instance.domain", PublicHost: "trusted.domain"}, + }, + { + name: "trusted public host with port", + domain: &http_util.DomainCtx{InstanceHost: "instance.domain", PublicHost: "trusted.domain:443"}, + }, + { + name: "untrusted public host", + domain: &http_util.DomainCtx{InstanceHost: "instance.domain", PublicHost: "evil.com"}, + wantErr: true, + }, + { + name: "invalid public host (non-numeric port)", + domain: &http_util.DomainCtx{InstanceHost: "instance.domain", PublicHost: "evil.com:443/"}, + wantErr: true, + }, + { + name: "invalid public host (userinfo)", + domain: &http_util.DomainCtx{InstanceHost: "instance.domain", PublicHost: "a:b@evil.com"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := instance.checkDomain(tt.domain.InstanceDomain(), tt.domain.RequestedDomain()) + if tt.wantErr { + require.Error(t, err) + assert.True(t, zerrors.IsNotFound(err)) + return + } + assert.NoError(t, err) + }) + } +}