fix(console): derive environment.json api from the requested origin (#12893)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# Which Problems Are Solved

- When an instance has multiple custom domains (or runs behind a reverse
proxy that forwards the original host), the `api` attribute of the
management console's `/ui/console/assets/environment.json` does not
match the requested domain, while `issuer` does.
- The `api` URL was built from the raw `r.Host` of the request, whereas
the issuer is derived from the domain context that the `WithOrigin`
middleware resolves from the instance / public host headers.

# How the Problems Are Solved

- The `api` URL is now taken from
`http_util.DomainContext(ctx).Origin()`, the same source the OIDC issuer
uses, so both values always match.

# Additional Changes

- Removes the now unused `externalSecure` parameter from
`console.Start`. The protocol is already part of the domain context
(`WithOrigin` is configured with `ExternalSecure`).
- Adds a unit test for the `environment.json` handler covering the
instance host, a public host and a forwarded instance host that differ
from the request host. The test fails on `main` (`api` is
`https://zitadel.internal:8080` instead of `https://custom.example.com`)
and passes with this change.

# Additional Context

- Closes #12009
- Closes #12195
- AI-assisted: this change was prepared with Claude Code and reviewed
and tested by me (`go test ./internal/api/ui/console/`, `go vet`, `go
build ./cmd/...`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 14:54:21 +02:00
1 parent 53730e6643
commit ce12560d24
3 files changed
+86 -3

No files matched your search

+1 -1
View File
@@ -727,7 +727,7 @@ func startAPIs(
instanceInterceptor.HandlerFuncWithError,
middleware.AuthorizationInterceptor(verifier, config.SystemAuthZ, config.InternalAuthZ).HandlerFuncWithError(schemas.HandlerPrefix)))
c, err := console.Start(config.Console, config.ExternalSecure, oidcServer.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, limitingAccessInterceptor, config.CustomerPortal)
c, err := console.Start(config.Console, oidcServer.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, limitingAccessInterceptor, config.CustomerPortal)
if err != nil {
return nil, fmt.Errorf("unable to start management console: %w", err)
}
+2 -2
View File
@@ -97,7 +97,7 @@ func (f *file) Stat() (_ fs.FileInfo, err error) {
return f, nil
}
func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, callDurationInterceptor, instanceHandler func(http.Handler) http.Handler, limitingAccessInterceptor *middleware.AccessInterceptor, customerPortal string) (http.Handler, error) {
func Start(config Config, issuer op.IssuerFromRequest, callDurationInterceptor, instanceHandler func(http.Handler) http.Handler, limitingAccessInterceptor *middleware.AccessInterceptor, customerPortal string) (http.Handler, error) {
fSys, err := fs.Sub(static, "static")
if err != nil {
return nil, err
@@ -122,7 +122,7 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call
instanceHandler,
)
env.HandleFunc("", func(w http.ResponseWriter, r *http.Request) {
url := http_util.BuildOrigin(r.Host, externalSecure)
url := http_util.DomainContext(r.Context()).Origin()
ctx := r.Context()
instance := authz.GetInstance(ctx)
instanceMgmtURL, err := templateInstanceManagementURL(config.InstanceManagementURL, instance)
+83
View File
@@ -0,0 +1,83 @@
package console
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
)
func TestStart_environmentJSON(t *testing.T) {
tests := []struct {
name string
externalSecure bool
requestHost string
headers map[string]string
wantAPI string
}{
{
name: "request host",
externalSecure: true,
requestHost: "zitadel.example.com",
wantAPI: "https://zitadel.example.com",
},
{
name: "x-forwarded-host differs from request host",
requestHost: "zitadel.internal:8080",
headers: map[string]string{
"X-Forwarded-Host": "custom.example.com",
},
wantAPI: "http://custom.example.com",
},
{
name: "forwarded header differs from request host",
requestHost: "zitadel.internal:8080",
headers: map[string]string{
"Forwarded": "host=custom.example.com;proto=https",
},
wantAPI: "https://custom.example.com",
},
{
name: "public host differs from instance host",
externalSecure: true,
requestHost: "zitadel.internal:8080",
headers: map[string]string{
"x-zitadel-instance-host": "zitadel.example.com",
"x-zitadel-public-host": "custom.example.com",
},
wantAPI: "https://custom.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
passthrough := func(next http.Handler) http.Handler { return next }
issuer := func(r *http.Request) string { return "" }
accessInterceptor := middleware.NewAccessInterceptor(&logstore.Service[*record.AccessLog]{}, nil, &middleware.AccessConfig{})
handler, err := Start(Config{}, issuer, passthrough, passthrough, accessInterceptor, "")
require.NoError(t, err)
handler = middleware.WithOrigin(tt.externalSecure, "host", ":authority", []string{"x-zitadel-instance-host"}, []string{"x-zitadel-public-host"})(handler)
req := httptest.NewRequest(http.MethodGet, envRequestPath, nil)
req.Host = tt.requestHost
for key, value := range tt.headers {
req.Header.Set(key, value)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var env struct {
API string `json:"api"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
assert.Equal(t, tt.wantAPI, env.API)
})
}
}