From fe935d91b87d4a57bc047d038ad8ecf220c30fb3 Mon Sep 17 00:00:00 2001 From: Livio Spring <9405495+livio-a@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:46:27 +0200 Subject: [PATCH] Merge commit from fork --- internal/api/http/domain_check.go | 13 +- internal/api/http/domain_check_test.go | 177 +++++++++++++++++++++++++ internal/command/command.go | 52 ++++---- 3 files changed, 212 insertions(+), 30 deletions(-) create mode 100644 internal/api/http/domain_check_test.go diff --git a/internal/api/http/domain_check.go b/internal/api/http/domain_check.go index 616c28cdfc..d3ac483bfe 100644 --- a/internal/api/http/domain_check.go +++ b/internal/api/http/domain_check.go @@ -20,10 +20,10 @@ const ( DNSPattern = "_zitadel-challenge.%s" ) -func ValidateDomain(domain, token, verifier string, checkType CheckType) error { +func ValidateDomain(domain, token, verifier string, checkType CheckType, client *http.Client) error { switch checkType { case CheckTypeHTTP: - return ValidateDomainHTTP(domain, token, verifier) + return ValidateDomainHTTP(domain, token, verifier, client) case CheckTypeDNS: return ValidateDomainDNS(domain, verifier) default: @@ -31,18 +31,21 @@ func ValidateDomain(domain, token, verifier string, checkType CheckType) error { } } -func ValidateDomainHTTP(domain, token, verifier string) error { - resp, err := http.Get(tokenUrlHTTP(domain, token)) +func ValidateDomainHTTP(domain, token, verifier string, client *http.Client) error { + if client == nil { + return zerrors.ThrowInternal(nil, "HTTP-NilCl", "Errors.Internal") + } + resp, err := client.Get(tokenUrlHTTP(domain, token)) if err != nil { return zerrors.ThrowInternal(err, "HTTP-BH42h", "Errors.Internal") } + defer resp.Body.Close() if resp.StatusCode != 200 { if resp.StatusCode == 404 { return zerrors.ThrowNotFound(err, "ORG-F4zhw", "Errors.Org.DomainVerificationHTTPNotFound") } return zerrors.ThrowInternal(err, "HTTP-G2zsw", "Errors.Internal") } - defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return zerrors.ThrowInternal(err, "HTTP-HB432", "Errors.Internal") diff --git a/internal/api/http/domain_check_test.go b/internal/api/http/domain_check_test.go new file mode 100644 index 0000000000..68f861b142 --- /dev/null +++ b/internal/api/http/domain_check_test.go @@ -0,0 +1,177 @@ +package http + +import ( + "crypto/tls" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/denylist" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const testVerifier = "challenge-token-value" + +func TestValidateDomainHTTP_Success(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/.well-known/zitadel-challenge/"+testVerifier+".txt", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(testVerifier)) + })) + t.Cleanup(server.Close) + + client := newDomainTestClient(t, &ClientConfig{ + MaxBodySize: 1024, + Timeout: 2 * time.Second, + MaxRedirects: 3, + DenyList: []denylist.AddressChecker{}, + }) + + err := ValidateDomainHTTP(hostFromURL(t, server.URL), testVerifier, testVerifier, client) + assert.NoError(t, err) +} + +func TestValidateDomainHTTP_RedirectToDenylistedURL(t *testing.T) { + t.Parallel() + + var blockedHits atomic.Int32 + blocked := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + blockedHits.Add(1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(testVerifier)) + })) + t.Cleanup(blocked.Close) + + // Redirect target uses hostname "localhost" while the challenge host is 127.0.0.1, + // so a domain-only denylist entry blocks the redirect without blocking the initial dial. + // Keep https:// so HTTPS-downgrade checks do not fire before the denylist. + blockedURL, err := url.Parse(blocked.URL) + require.NoError(t, err) + blockedURL.Host = "localhost:" + blockedURL.Port() + + public := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, blockedURL.String(), http.StatusFound) + })) + t.Cleanup(public.Close) + + client := newDomainTestClient(t, &ClientConfig{ + MaxBodySize: 1024, + Timeout: 2 * time.Second, + MaxRedirects: 3, + DenyList: []denylist.AddressChecker{denylist.NewHostChecker("localhost")}, + }) + + err = ValidateDomainHTTP(hostFromURL(t, public.URL), testVerifier, testVerifier, client) + require.Error(t, err) + assert.True(t, zerrors.IsInternal(err)) + assert.ErrorIs(t, err, denylist.NewAddressDeniedError("localhost")) + assert.Equal(t, int32(0), blockedHits.Load(), "denylisted redirect target must not be dialed") +} + +func TestValidateDomainHTTP_HTTPSDowngradeBlocked(t *testing.T) { + t.Parallel() + + httpTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(testVerifier)) + })) + t.Cleanup(httpTarget.Close) + + public := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, httpTarget.URL, http.StatusFound) + })) + t.Cleanup(public.Close) + + client := newDomainTestClient(t, &ClientConfig{ + MaxBodySize: 1024, + Timeout: 2 * time.Second, + MaxRedirects: 3, + AllowHTTPSDowngrade: false, + DenyList: []denylist.AddressChecker{}, + }) + + err := ValidateDomainHTTP(hostFromURL(t, public.URL), testVerifier, testVerifier, client) + require.Error(t, err) + assert.True(t, zerrors.IsInternal(err)) + assert.ErrorIs(t, err, ErrHTTPsDowngrade) +} + +func TestValidateDomainHTTP_OversizedBody(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("0123456789")) // 10 bytes + })) + t.Cleanup(server.Close) + + client := newDomainTestClient(t, &ClientConfig{ + MaxBodySize: 5, + Timeout: 2 * time.Second, + MaxRedirects: 3, + DenyList: []denylist.AddressChecker{}, + }) + + err := ValidateDomainHTTP(hostFromURL(t, server.URL), testVerifier, testVerifier, client) + require.Error(t, err) + assert.True(t, zerrors.IsInternal(err)) + assert.True(t, errors.Is(err, ErrResponseTooLarge), "got: %v", err) +} + +func TestValidateDomainHTTP_NilClient(t *testing.T) { + t.Parallel() + + err := ValidateDomainHTTP("example.com", testVerifier, testVerifier, nil) + require.Error(t, err) + assert.True(t, zerrors.IsInternal(err)) +} + +func TestValidateDomain_HTTPUsesClient(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(testVerifier)) + })) + t.Cleanup(server.Close) + + client := newDomainTestClient(t, &ClientConfig{ + MaxBodySize: 1024, + Timeout: 2 * time.Second, + MaxRedirects: 3, + DenyList: []denylist.AddressChecker{}, + }) + + err := ValidateDomain(hostFromURL(t, server.URL), testVerifier, testVerifier, CheckTypeHTTP, client) + assert.NoError(t, err) +} + +func newDomainTestClient(t *testing.T, cfg *ClientConfig) *http.Client { + t.Helper() + client := cfg.NewClient() + + // Challenge URLs are always https://…; trust httptest TLS certs. + transport, ok := client.Transport.(*MaxBytesRoundTripper) + require.True(t, ok) + httpTransport, ok := transport.Underlying.(*http.Transport) + require.True(t, ok) + httpTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test-only + + return client +} + +func hostFromURL(t *testing.T, raw string) string { + t.Helper() + u, err := url.Parse(raw) + require.NoError(t, err) + return u.Host +} diff --git a/internal/command/command.go b/internal/command/command.go index d11d624e5e..078b48a165 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -164,31 +164,33 @@ func StartCommands( } ipLookupFunction := net.LookupIP repo = &Commands{ - eventstore: es, - static: staticStore, - idGenerator: idGenerator, - zitadelRoles: zitadelRoles, - externalDomain: externalDomain, - externalSecure: externalSecure, - externalPort: externalPort, - keySize: defaults.KeyConfig.Size, - certKeySize: defaults.KeyConfig.CertificateSize, - privateKeyLifetime: defaults.KeyConfig.PrivateKeyLifetime, - publicKeyLifetime: defaults.KeyConfig.PublicKeyLifetime, - certificateLifetime: defaults.KeyConfig.CertificateLifetime, - maxIdPIntentLifetime: defaults.MaxIdPIntentLifetime, - idpConfigEncryption: idpConfigEncryption, - smtpEncryption: smtpEncryption, - smsEncryption: smsEncryption, - userEncryption: userEncryption, - targetEncryption: targetEncryption, - userPasswordHasher: userPasswordHasher, - secretHasher: secretHasher, - machineKeySize: int(defaults.SecretGenerators.MachineKeySize), - applicationKeySize: int(defaults.SecretGenerators.ApplicationKeySize), - domainVerificationAlg: domainVerificationEncryption, - domainVerificationGenerator: crypto.NewEncryptionGenerator(defaults.DomainVerification.VerificationGenerator, domainVerificationEncryption), - domainVerificationValidator: api_http.ValidateDomain, + eventstore: es, + static: staticStore, + idGenerator: idGenerator, + zitadelRoles: zitadelRoles, + externalDomain: externalDomain, + externalSecure: externalSecure, + externalPort: externalPort, + keySize: defaults.KeyConfig.Size, + certKeySize: defaults.KeyConfig.CertificateSize, + privateKeyLifetime: defaults.KeyConfig.PrivateKeyLifetime, + publicKeyLifetime: defaults.KeyConfig.PublicKeyLifetime, + certificateLifetime: defaults.KeyConfig.CertificateLifetime, + maxIdPIntentLifetime: defaults.MaxIdPIntentLifetime, + idpConfigEncryption: idpConfigEncryption, + smtpEncryption: smtpEncryption, + smsEncryption: smsEncryption, + userEncryption: userEncryption, + targetEncryption: targetEncryption, + userPasswordHasher: userPasswordHasher, + secretHasher: secretHasher, + machineKeySize: int(defaults.SecretGenerators.MachineKeySize), + applicationKeySize: int(defaults.SecretGenerators.ApplicationKeySize), + domainVerificationAlg: domainVerificationEncryption, + domainVerificationGenerator: crypto.NewEncryptionGenerator(defaults.DomainVerification.VerificationGenerator, domainVerificationEncryption), + domainVerificationValidator: func(domain, token, verifier string, checkType api_http.CheckType) error { + return api_http.ValidateDomain(domain, token, verifier, checkType, httpClient) + }, keyAlgorithm: oidcEncryption, authAlgorithm: oidcEncryption, certificateAlgorithm: samlEncryption,