Merge commit from fork

# Which Problems Are Solved

A public host header (`x-zitadel-public-host`) that failed sanitization, e.g. because of a non-numeric port (`evil.com:443/`) or userinfo (`a:b@evil.com`), was silently ignored. The request then fell back to the instance host, which skipped the trusted domain check.
Login V2 builds links such as password reset, invite and email verification from the same host it forwards in this header, which allowed an unauthenticated attacker to send genuine emails containing valid codes to an attacker controlled host.

# How the Problems Are Solved

If a public host header is present but invalid, the raw value is kept as the public host, so the trusted domain check rejects the request instead of skipping it.
Invalid instance host headers are still ignored and fall back to the request host as before.

# Additional Changes

- Added tests for the trusted domain check.

# Additional Context

- requires backport to v4.x

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Livio SpringandClaude Opus 5.5 authored and GitHub committed 2026-10-15 09:30:58 +02:00
1 parent 7898daa47d
commit c94e2865bc
3 files changed
+137 -4

No files matched your search

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