diff --git a/apps/api/test-integration-api.yaml b/apps/api/test-integration-api.yaml index 2f921f611..833d64fff 100644 --- a/apps/api/test-integration-api.yaml +++ b/apps/api/test-integration-api.yaml @@ -77,6 +77,7 @@ LogStore: Enabled: true Executions: + # Deprecated: use HTTPClient.DenyList DenyList: # ZITADEL_EXECUTIONS_DENYLIST (comma separated list) Projections: @@ -129,4 +130,7 @@ OIDC: DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 SAML: - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 \ No newline at end of file + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 + +HTTPClient: + DenyList: \ No newline at end of file diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index bae378979..b026936ed 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -598,12 +598,9 @@ Executions: MaxTtl: 5m # ZITADEL_EXECUTIONS_MAXTTL # List of domains and IPs that are not valid execution target's endpoints # Wildcard sub domains are currently unsupported + + # Deprecated: use HTTPClient.DenyList instead. If both are set, this list will be merged into the HTTPClient.DenyList. DenyList: # ZITADEL_EXECUTIONS_DENYLIST (comma separated list) - - localhost - - "127.0.0.0/8" - - "::1" - - "0.0.0.0" - - "::" Auth: # See Projections.BulkLimit @@ -1033,12 +1030,8 @@ SystemDefaults: Actions: HTTP: # Wildcard sub domains are currently unsupported + # Deprecated: use HTTPClient.DenyList instead. If both are set, this list will be merged into the HTTPClient.DenyList. DenyList: # ZITADEL_ACTIONS_HTTP_DENYLIST (comma separated list) - - localhost - - "127.0.0.0/8" - - "::1" - - "0.0.0.0" - - "::" LogStore: Access: @@ -1072,6 +1065,38 @@ Eventstore: # Maximum amount of push retries in case of primary key violation on the sequence MaxRetries: 5 #ZITADEL_EVENTSTORE_MAXRETRIES +HTTPClient: + # Sets the limit of the response body size in bytes, which will be processed by the HTTP client. + # If the response body exceeds this limit, the client will return an error. + # The default value is 20MB. + MaxBodySize: 20971520 # ZITADEL_HTTPCLIENT_MAXBODYSIZE + # Sets the timeout for the HTTP client. + Timeout: 30s # ZITADEL_HTTPCLIENT_TIMEOUT + # Sets the number of allowed redirects for the HTTP client. + MaxRedirects: 5 # ZITADEL_HTTPCLIENT_MAXREDIRECTS + # Specifies if the HTTP client should follow redirects from HTTPS to HTTP. + AllowHTTPSDowngrade: false # ZITADEL_HTTPCLIENT_ALLOWHTTPSDOWNGRADE + # Specifies the list of IP addresses or CIDR ranges that are not allowed to be called by the HTTP client. + # Note that hosts / domains are matched best-case and wildcards are not supported. + # CRITICAL SECURITY NOTE: Domain rules (like "localhost") protect application-layer string matches. + # Underneath, the network dialer relies exclusively on the CIDR ranges below to block resolved IPs and prevent DNS Rebinding. + # Do not remove the CIDR subnets even if a domain mapping exists. + DenyList: # ZITADEL_HTTPCLIENT_DENYLIST (comma separated list) + - localhost + - "0.0.0.0/8" # Unspecified IPv4 address range / Local network routing trick protection + - "10.0.0.0/8" # Class A private networks + - "100.64.0.0/10" # Carrier-Grade NAT space + - "127.0.0.0/8" # Loopback addresses (IPv4) + - "169.254.0.0/16" # Link-local subnets (Covers Cloud Metadata Endpoints like AWS/GCP 169.254.169.254) + - "172.16.0.0/12" # Class B private networks + - "192.168.0.0/16" # Class C private networks + - "198.18.0.0/15" # Benchmark/inter-network communication testing + - "::/128" # Unspecified IPv6 address + - "::1/128" # IPv6 loopback address + - "::ffff:0:0/96" # IPv4-mapped IPv6 addresses (Prevents IPv6 encapsulation bypasses) + - "fc00::/7" # Unique local addresses / IPv6 equivalent to private IPv4 subnets + - "fe80::/10" # Link-local addresses / IPv6 equivalent to cloud metadata subnets + # The DefaultInstance section defines the default values for each new virtual instance that is created. # Check out https://zitadel.com/docs/concepts/structure/instance#multiple-virtual-instances for more information about virtual instances. # For the initial setup, the default values are used to create the first instance. diff --git a/cmd/mirror/defaults.yaml b/cmd/mirror/defaults.yaml index 109e4cd22..cf74ada74 100644 --- a/cmd/mirror/defaults.yaml +++ b/cmd/mirror/defaults.yaml @@ -211,3 +211,35 @@ FirstInstance: Log: Level: info + +HTTPClient: + # Sets the limit of the response body size in bytes, which will be processed by the HTTP client. + # If the response body exceeds this limit, the client will return an error. + # The default value is 20MB. + MaxBodySize: 20971520 # ZITADEL_HTTPCLIENT_MAXBODYSIZE + # Sets the timeout for the HTTP client. + Timeout: 30s # ZITADEL_HTTPCLIENT_TIMEOUT + # Sets the number of allowed redirects for the HTTP client. + MaxRedirects: 5 # ZITADEL_HTTPCLIENT_MAXREDIRECTS + # Specifies if the HTTP client should follow redirects from HTTPS to HTTP. + AllowHTTPSDowngrade: false # ZITADEL_HTTPCLIENT_ALLOWHTTPSDOWNGRADE + # Specifies the list of IP addresses or CIDR ranges that are not allowed to be called by the HTTP client. + # Note that hosts / domains are matched best-case and wildcards are not supported. + # CRITICAL SECURITY NOTE: Domain rules (like "localhost") protect application-layer string matches. + # Underneath, the network dialer relies exclusively on the CIDR ranges below to block resolved IPs and prevent DNS Rebinding. + # Do not remove the CIDR subnets even if a domain mapping exists. + DenyList: # ZITADEL_HTTPCLIENT_DENYLIST (comma separated list) + - localhost + - "0.0.0.0/8" # Unspecified IPv4 address range / Local network routing trick protection + - "10.0.0.0/8" # Class A private networks + - "100.64.0.0/10" # Carrier-Grade NAT space + - "127.0.0.0/8" # Loopback addresses (IPv4) + - "169.254.0.0/16" # Link-local subnets (Covers Cloud Metadata Endpoints like AWS/GCP 169.254.169.254) + - "172.16.0.0/12" # Class B private networks + - "192.168.0.0/16" # Class C private networks + - "198.18.0.0/15" # Benchmark/inter-network communication testing + - "::/128" # Unspecified IPv6 address + - "::1/128" # IPv6 loopback address + - "::ffff:0:0/96" # IPv4-mapped IPv6 addresses (Prevents IPv6 encapsulation bypasses) + - "fc00::/7" # Unique local addresses / IPv6 equivalent to private IPv4 subnets + - "fe80::/10" # Link-local addresses / IPv6 equivalent to cloud metadata subnets \ No newline at end of file diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index f61b02ffe..e3df16b57 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "net/http" "sync" "time" @@ -22,6 +21,7 @@ import ( admin_handler "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/handler" admin_view "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/view" internal_authz "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/ui/login" auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing" @@ -34,7 +34,6 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" crypto_db "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" @@ -109,6 +108,7 @@ type ProjectionsConfig struct { WebAuthNName string DefaultInstance command.InstanceSetup AssetStorage static_config.AssetStorageConfig + HTTPClient http.ClientConfig } func migrateProjectionsFlags(cmd *cobra.Command) { @@ -187,6 +187,9 @@ func projections( DisplayName: config.WebAuthNName, ExternalSecure: config.ExternalSecure, } + + httpClient := config.HTTPClient.NewClient() + commands, err := command.StartCommands(ctx, es, cacheConnectors, @@ -206,7 +209,7 @@ func projections( keys.SAML, keys.Target, keys.OIDC, - &http.Client{}, + httpClient, func(ctx context.Context, permission, orgID, resourceID string) (err error) { return internal_authz.CheckPermission(ctx, authZRepo, config.SystemAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) }, @@ -216,7 +219,7 @@ func projections( config.OIDC.DefaultRefreshTokenIdleExpiration, config.DefaultInstance.SecretGenerators, config.Login.DefaultPaths, - []denylist.AddressChecker{}, + config.HTTPClient.DenyList, ) logging.OnError(ctx, err).Fatal("unable to start commands") @@ -246,6 +249,7 @@ func projections( keys.SMTP, keys.SMS, nil, + httpClient, ) config.Auth.Spooler.Client = client diff --git a/cmd/setup/03.go b/cmd/setup/03.go index c995f6069..95d444c3f 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -3,6 +3,7 @@ package setup import ( "context" "fmt" + "net/http" "os" "strings" @@ -47,6 +48,8 @@ type FirstInstance struct { externalPort uint16 domain string defaultPaths *login.DefaultPaths + httpClient *http.Client + denylist []denylist.AddressChecker } func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error { @@ -89,7 +92,7 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error nil, nil, oidcEncryption, - nil, + mig.httpClient, nil, nil, 0, @@ -97,7 +100,7 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error 0, nil, mig.defaultPaths, - []denylist.AddressChecker{}, + mig.denylist, ) if err != nil { return err diff --git a/cmd/setup/config.go b/cmd/setup/config.go index e2ffe138f..295f2b479 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -19,6 +19,7 @@ import ( "github.com/zitadel/zitadel/cmd/hooks" "github.com/zitadel/zitadel/internal/actions" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/cache/connector" @@ -64,6 +65,7 @@ type Config struct { WebAuthNName string Telemetry *handlers.TelemetryPusherConfig SystemAPIUsers map[string]*authz.SystemAPIUser + HTTPClient http.ClientConfig } type InitProjections struct { @@ -114,6 +116,8 @@ func NewConfig(cmd *cobra.Command, v *viper.Viper) (*Config, instrumentation.Shu id.Configure(config.Machine) + config.HTTPClient.MergeDeprecatedDenylists(nil, config.Executions.DenyList) + // Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API. config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings diff --git a/cmd/setup/config_change.go b/cmd/setup/config_change.go index 8096c9fb0..97263eb69 100644 --- a/cmd/setup/config_change.go +++ b/cmd/setup/config_change.go @@ -7,7 +7,6 @@ import ( "github.com/zitadel/zitadel/internal/cache/connector" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/systemdefaults" - "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/eventstore" ) @@ -61,7 +60,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context, _ eventstore.Event 0, nil, &login.DefaultPaths{}, - []denylist.AddressChecker{}, + nil, ) if err != nil { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 78d1c2632..bd5430d92 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -170,6 +170,8 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) MaxRetries: config.Eventstore.MaxRetries, })) + httpClient := config.HTTPClient.NewClient() + steps.s1ProjectionTable = &ProjectionTable{dbClient: dbClient.DB} steps.s2AssetsTable = &AssetTable{dbClient: dbClient.DB} @@ -187,6 +189,8 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.FirstInstance.externalSecure = config.ExternalSecure steps.FirstInstance.externalPort = config.ExternalPort steps.FirstInstance.defaultPaths = config.Login.DefaultPaths + steps.FirstInstance.httpClient = httpClient + steps.FirstInstance.denylist = config.HTTPClient.DenyList steps.s5LastFailed = &LastFailed{dbClient: dbClient.DB} steps.s6OwnerRemoveColumns = &OwnerRemoveColumns{dbClient: dbClient.DB} @@ -315,7 +319,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) } } - commands, _, _, _ := startCommandsQueries(ctx, eventstoreClient, eventstoreV4, dbClient, masterKey, config) + commands, _, _, _ := startCommandsQueries(ctx, eventstoreClient, eventstoreV4, dbClient, masterKey, config, httpClient) steps.s59SetupWebkeys = &SetupWebkeys{eventstore: eventstoreClient, commands: commands} repeatableSteps := []migration.RepeatableMigration{ @@ -454,6 +458,7 @@ func startCommandsQueries( dbClient *database.DB, masterKey string, config *Config, + httpClient *http.Client, ) ( *command.Commands, *query.Queries, @@ -572,7 +577,7 @@ func startCommandsQueries( keys.SAML, keys.Target, keys.OIDC, - &http.Client{}, + httpClient, permissionCheck, sessionTokenVerifier, config.OIDC.DefaultAccessTokenLifetime, @@ -580,7 +585,7 @@ func startCommandsQueries( config.OIDC.DefaultRefreshTokenIdleExpiration, config.DefaultInstance.SecretGenerators, config.Login.DefaultPaths, - config.Executions.DenyList, + config.HTTPClient.DenyList, ) logging.OnError(ctx, err).Fatal("unable to start commands") @@ -610,6 +615,7 @@ func startCommandsQueries( keys.SMTP, keys.SMS, q, + httpClient, ) return commands, queries, adminView, authView diff --git a/cmd/start/config.go b/cmd/start/config.go index 95515c550..1ddc9c71b 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -17,6 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/actions" admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/saml" @@ -87,6 +88,7 @@ type Config struct { Quotas *QuotasConfig Telemetry *handlers.TelemetryPusherConfig ServicePing *serviceping.Config + HTTPClient *http.ClientConfig } type QuotasConfig struct { @@ -121,9 +123,12 @@ func NewConfig(cmd *cobra.Command, v *viper.Viper) (*Config, instrumentation.Shu } id.Configure(config.Machine) + + var actionsDenylist []denylist.AddressChecker if config.Actions != nil { - actions.SetHTTPConfig(&config.Actions.HTTP) + actionsDenylist = config.Actions.HTTP.DenyList } + config.HTTPClient.MergeDeprecatedDenylists(actionsDenylist, config.Executions.DenyList) err = config.SystemDefaults.Validate() if err != nil { diff --git a/cmd/start/start.go b/cmd/start/start.go index 02b77a993..80b719d04 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -272,6 +272,8 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server new_domain.SetWebAuthNConfig(webAuthNConfig) + httpClient := config.HTTPClient.NewClient() + commands, err := command.StartCommands(ctx, eventstoreClient, cacheConnectors, @@ -291,7 +293,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.SAML, keys.Target, keys.OIDC, - &http.Client{}, + httpClient, permissionCheck, sessionTokenVerifier, config.OIDC.DefaultAccessTokenLifetime, @@ -299,7 +301,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server config.OIDC.DefaultRefreshTokenIdleExpiration, config.DefaultInstance.SecretGenerators, config.Login.DefaultPaths, - config.Executions.DenyList, + config.HTTPClient.DenyList, ) if err != nil { return fmt.Errorf("cannot start commands: %w", err) @@ -345,12 +347,14 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.SMTP, keys.SMS, q, + httpClient, ) notification.Start(ctx) execution.Register( ctx, config.Executions, + httpClient, q, keys.Target, queries.GetActiveSigningWebKey, @@ -390,6 +394,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys, permissionCheck, cacheConnectors, + httpClient, ) if err != nil { return err @@ -435,6 +440,7 @@ func startAPIs( keys *encryption.EncryptionKeys, permissionCheck domain.PermissionCheck, cacheConnectors connector.Connectors, + httpClient *http.Client, ) (*api.API, error) { repo := struct { authz_repo.Repository @@ -492,7 +498,7 @@ func startAPIs( keys.Target, translator, config.Instrumentation.Trace.TrustRemoteSpans, - config.Executions.DenyList, + httpClient, ) if err != nil { return nil, fmt.Errorf("error creating api %w", err) @@ -675,13 +681,29 @@ func startAPIs( config.Log.Slog(), config.SystemDefaults.SecretHasher, federatedLogoutsCache, + httpClient, ) if err != nil { return nil, fmt.Errorf("unable to start oidc provider: %w", err) } apis.RegisterHandlerPrefixes(oidcServer, oidcPrefixes...) - samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, keys.Target, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, limitingAccessInterceptor) + samlProvider, err := saml.NewProvider( + config.SAML, + config.ExternalSecure, + commands, + queries, + authRepo, + keys.OIDC, + keys.SAML, + keys.Target, + eventstore, + dbClient, + instanceInterceptor.Handler, + userAgentInterceptor, + limitingAccessInterceptor, + httpClient, + ) if err != nil { return nil, fmt.Errorf("unable to start saml provider: %w", err) } @@ -726,6 +748,7 @@ func startAPIs( keys.CSRFCookieKey, cacheConnectors, federatedLogoutsCache, + httpClient, ) if err != nil { return nil, fmt.Errorf("unable to start login: %w", err) diff --git a/internal/actions/http_module.go b/internal/actions/http_module.go index b10c62e5e..6a61f5080 100644 --- a/internal/actions/http_module.go +++ b/internal/actions/http_module.go @@ -5,22 +5,19 @@ import ( "context" "encoding/json" "io" - "net" "net/http" "strings" "time" "github.com/dop251/goja" "github.com/zitadel/logging" - - "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/zerrors" ) -func WithHTTP(ctx context.Context) Option { +func WithHTTP(ctx context.Context, client *http.Client) Option { return func(c *runConfig) { c.modules["zitadel/http"] = func(runtime *goja.Runtime, module *goja.Object) { - requireHTTP(ctx, &http.Client{Transport: &transport{lookup: net.LookupIP}}, runtime, module) + requireHTTP(ctx, client, runtime, module) } } } @@ -170,18 +167,3 @@ func parseHeaders(headers *goja.Object) http.Header { } return h } - -type transport struct { - lookup func(string) ([]net.IP, error) -} - -func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { - if httpConfig == nil || len(httpConfig.DenyList) == 0 { - return http.DefaultTransport.RoundTrip(req) - } - - if err := denylist.IsHostBlocked(httpConfig.DenyList, req.URL, t.lookup); err != nil { - return nil, zerrors.ThrowInvalidArgument(err, "ACTIO-N72d0", "host is denied") - } - return http.DefaultTransport.RoundTrip(req) -} diff --git a/internal/actions/http_module_config.go b/internal/actions/http_module_config.go index 2dd5adf2b..37a42ba24 100644 --- a/internal/actions/http_module_config.go +++ b/internal/actions/http_module_config.go @@ -8,12 +8,6 @@ import ( "github.com/zitadel/zitadel/internal/denylist" ) -func SetHTTPConfig(config *HTTPConfig) { - httpConfig = config -} - -var httpConfig *HTTPConfig - type HTTPConfig struct { DenyList []denylist.AddressChecker } diff --git a/internal/api/api.go b/internal/api/api.go index f322ad3f7..20c73aa33 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -26,7 +26,6 @@ import ( http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -58,7 +57,7 @@ type API struct { targetEncryptionAlgorithm crypto.EncryptionAlgorithm translator *i18n.Translator connectOTELInterceptor *otelconnect.Interceptor - actionV2DenyList []denylist.AddressChecker + httpClient *http.Client } func (a *API) ListGrpcServices() []string { @@ -113,7 +112,7 @@ func New( targetEncryptionAlgorithm crypto.EncryptionAlgorithm, translator *i18n.Translator, trustRemoteSpans bool, - deniedIPList []denylist.AddressChecker, + httpClient *http.Client, ) (_ *API, err error) { api := &API{ port: port, @@ -129,10 +128,10 @@ func New( connectServices: make(map[string][]string), targetEncryptionAlgorithm: targetEncryptionAlgorithm, translator: translator, - actionV2DenyList: deniedIPList, + httpClient: httpClient, } - api.grpcServer = server.CreateServer(api.verifier, systemAuthz, authZ, queries, externalDomain, tlsConfig, accessInterceptor.AccessService(), targetEncryptionAlgorithm, api.translator, deniedIPList) + api.grpcServer = server.CreateServer(api.verifier, systemAuthz, authZ, queries, externalDomain, tlsConfig, accessInterceptor.AccessService(), targetEncryptionAlgorithm, api.translator, httpClient) api.grpcGateway, err = server.CreateGateway(ctx, port, hostHeaders, accessInterceptor, tlsConfig) if err != nil { return nil, err @@ -224,7 +223,7 @@ func (a *API) registerConnectServer(service server.ConnectServer) { connect_middleware.AuthorizationInterceptor(a.verifier, a.systemAuthZ, a.authConfig), connect_middleware.TranslationHandler(), connect_middleware.QuotaExhaustedInterceptor(a.accessInterceptor.AccessService(), system_pb.SystemService_ServiceDesc.ServiceName), - connect_middleware.ExecutionHandler(a.targetEncryptionAlgorithm, a.queries.GetActiveSigningWebKey, a.actionV2DenyList), + connect_middleware.ExecutionHandler(a.targetEncryptionAlgorithm, a.queries.GetActiveSigningWebKey, a.httpClient), connect_middleware.ValidationHandler(), connect_middleware.ServiceHandler(), connect_middleware.ActivityInterceptor(), diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor.go b/internal/api/grpc/server/connect_middleware/execution_interceptor.go index be57ab8f2..a7f2ac872 100644 --- a/internal/api/grpc/server/connect_middleware/execution_interceptor.go +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor.go @@ -13,7 +13,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/execution" target_domain "github.com/zitadel/zitadel/internal/execution/target" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -27,12 +26,12 @@ var headersToForward = map[string]bool{ strings.ToLower(http_utils.Origin): true, } -func ExecutionHandler(alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, deniedIPList []denylist.AddressChecker) connect.UnaryInterceptorFunc { +func ExecutionHandler(alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, httpClient *http.Client) connect.UnaryInterceptorFunc { return func(handler connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { requestTargets := execution.QueryExecutionTargetsForRequest(ctx, req.Spec().Procedure) - handledReq, err := executeTargetsForRequest(ctx, requestTargets, req.Spec().Procedure, req, alg, activeSigningKey, deniedIPList) + handledReq, err := executeTargetsForRequest(ctx, requestTargets, req.Spec().Procedure, req, alg, activeSigningKey, httpClient) if err != nil { return nil, err } @@ -43,12 +42,12 @@ func ExecutionHandler(alg crypto.EncryptionAlgorithm, activeSigningKey execution } responseTargets := execution.QueryExecutionTargetsForResponse(ctx, req.Spec().Procedure) - return executeTargetsForResponse(ctx, responseTargets, req.Spec().Procedure, handledReq, response, alg, activeSigningKey, deniedIPList) + return executeTargetsForResponse(ctx, responseTargets, req.Spec().Procedure, handledReq, response, alg, activeSigningKey, httpClient) } } } -func executeTargetsForRequest(ctx context.Context, targets []target_domain.Target, fullMethod string, req connect.AnyRequest, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, deniedIPList []denylist.AddressChecker) (_ connect.AnyRequest, err error) { +func executeTargetsForRequest(ctx context.Context, targets []target_domain.Target, fullMethod string, req connect.AnyRequest, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, httpClient *http.Client) (_ connect.AnyRequest, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -68,14 +67,14 @@ func executeTargetsForRequest(ctx context.Context, targets []target_domain.Targe Headers: SetRequestHeaders(req.Header()), } - _, err = execution.CallTargets(ctx, targets, info, alg, activeSigningKey, deniedIPList) + _, err = execution.CallTargets(ctx, targets, info, alg, activeSigningKey, httpClient) if err != nil { return nil, err } return req, nil } -func executeTargetsForResponse(ctx context.Context, targets []target_domain.Target, fullMethod string, req connect.AnyRequest, resp connect.AnyResponse, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, deniedIPList []denylist.AddressChecker) (_ connect.AnyResponse, err error) { +func executeTargetsForResponse(ctx context.Context, targets []target_domain.Target, fullMethod string, req connect.AnyRequest, resp connect.AnyResponse, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, httpClient *http.Client) (_ connect.AnyResponse, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -96,7 +95,7 @@ func executeTargetsForResponse(ctx context.Context, targets []target_domain.Targ Headers: SetRequestHeaders(req.Header()), } - _, err = execution.CallTargets(ctx, targets, info, alg, activeSigningKey, deniedIPList) + _, err = execution.CallTargets(ctx, targets, info, alg, activeSigningKey, httpClient) if err != nil { return nil, err } diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go index 2d979cd43..5140a0470 100644 --- a/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go @@ -75,9 +75,7 @@ func newMockContextInfoResponse(fullMethod, request, response string) *ContextIn } func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { - deniedLocalhost, err := denylist.NewHostChecker("127.0.0.1") - require.NoError(t, err) - deniedIPs := []denylist.AddressChecker{deniedLocalhost} + deniedIPs := []denylist.AddressChecker{denylist.NewHostChecker("127.0.0.1")} type target struct { reqBody execution.ContextInfo @@ -668,7 +666,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { tt.args.req, nil, tt.args.getActiveSigningWebKey, - tt.args.deniedIPs, + &http.Client{Transport: denylist.NewHTTPTransport(tt.args.deniedIPs)}, ) if tt.res.wantErr { @@ -774,9 +772,7 @@ func validateJWEPayload(t *testing.T) func(expected, sent []byte) bool { } func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { - deniedLocalhost, err := denylist.NewHostChecker("127.0.0.1") - require.NoError(t, err) - deniedIPs := []denylist.AddressChecker{deniedLocalhost} + deniedIPs := []denylist.AddressChecker{denylist.NewHostChecker("127.0.0.1")} type target struct { reqBody execution.ContextInfo sleep time.Duration @@ -942,7 +938,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { tt.args.resp, nil, mockGetActiveSigningWebKey(), - tt.args.deniedIPs, + &http.Client{Transport: denylist.NewHTTPTransport(tt.args.deniedIPs)}, ) if tt.res.wantErr { diff --git a/internal/api/grpc/server/middleware/execution_interceptor.go b/internal/api/grpc/server/middleware/execution_interceptor.go index 6aacadd08..b034d9358 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor.go +++ b/internal/api/grpc/server/middleware/execution_interceptor.go @@ -13,17 +13,16 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/execution" target_domain "github.com/zitadel/zitadel/internal/execution/target" "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func ExecutionHandler(alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, deniedIPList []denylist.AddressChecker) grpc.UnaryServerInterceptor { +func ExecutionHandler(alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, httpClient *http.Client) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { requestTargets := execution.QueryExecutionTargetsForRequest(ctx, info.FullMethod) // call targets otherwise return req - handledReq, err := executeTargetsForRequest(ctx, requestTargets, info.FullMethod, req, alg, activeSigningKey, deniedIPList) + handledReq, err := executeTargetsForRequest(ctx, requestTargets, info.FullMethod, req, alg, activeSigningKey, httpClient) if err != nil { return nil, err } @@ -34,11 +33,11 @@ func ExecutionHandler(alg crypto.EncryptionAlgorithm, activeSigningKey execution } responseTargets := execution.QueryExecutionTargetsForResponse(ctx, info.FullMethod) - return executeTargetsForResponse(ctx, responseTargets, info.FullMethod, handledReq, response, alg, activeSigningKey, deniedIPList) + return executeTargetsForResponse(ctx, responseTargets, info.FullMethod, handledReq, response, alg, activeSigningKey, httpClient) } } -func executeTargetsForRequest(ctx context.Context, targets []target_domain.Target, fullMethod string, req any, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, deniedIPList []denylist.AddressChecker) (_ interface{}, err error) { +func executeTargetsForRequest(ctx context.Context, targets []target_domain.Target, fullMethod string, req any, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, httpClient *http.Client) (_ interface{}, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -59,10 +58,10 @@ func executeTargetsForRequest(ctx context.Context, targets []target_domain.Targe Headers: connect_middleware.SetRequestHeaders(md), } - return execution.CallTargets(ctx, targets, info, alg, activeSigningKey, deniedIPList) + return execution.CallTargets(ctx, targets, info, alg, activeSigningKey, httpClient) } -func executeTargetsForResponse(ctx context.Context, targets []target_domain.Target, fullMethod string, req, resp interface{}, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, deniedIPList []denylist.AddressChecker) (_ interface{}, err error) { +func executeTargetsForResponse(ctx context.Context, targets []target_domain.Target, fullMethod string, req, resp interface{}, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey, httpClient *http.Client) (_ interface{}, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -84,7 +83,7 @@ func executeTargetsForResponse(ctx context.Context, targets []target_domain.Targ Headers: connect_middleware.SetRequestHeaders(md), } - return execution.CallTargets(ctx, targets, info, alg, activeSigningKey, deniedIPList) + return execution.CallTargets(ctx, targets, info, alg, activeSigningKey, httpClient) } var _ execution.ContextInfo = &ContextInfoRequest{} diff --git a/internal/api/grpc/server/middleware/execution_interceptor_test.go b/internal/api/grpc/server/middleware/execution_interceptor_test.go index 439c24144..6fc51209c 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor_test.go +++ b/internal/api/grpc/server/middleware/execution_interceptor_test.go @@ -64,9 +64,7 @@ func newMockContextInfoResponse(fullMethod, request, response string) *ContextIn } func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { - deniedLocalhost, err := denylist.NewHostChecker("127.0.0.1") - require.NoError(t, err) - deniedIPs := []denylist.AddressChecker{deniedLocalhost} + deniedIPs := []denylist.AddressChecker{denylist.NewHostChecker("127.0.0.1")} type target struct { reqBody execution.ContextInfo @@ -656,7 +654,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { tt.args.req, nil, tt.args.getActiveSigningWebKey, - tt.args.deniedIPs, + &http.Client{Transport: denylist.NewHTTPTransport(tt.args.deniedIPs)}, ) if tt.res.wantErr { @@ -762,9 +760,7 @@ func validateJWEPayload(t *testing.T) func(expected, sent []byte) bool { } func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { - deniedLocalhost, err := denylist.NewHostChecker("127.0.0.1") - require.NoError(t, err) - deniedIPs := []denylist.AddressChecker{deniedLocalhost} + deniedIPs := []denylist.AddressChecker{denylist.NewHostChecker("127.0.0.1")} type target struct { reqBody execution.ContextInfo @@ -930,7 +926,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { tt.args.resp, nil, mockGetActiveSigningWebKey(), - tt.args.deniedIPs, + &http.Client{Transport: denylist.NewHTTPTransport(tt.args.deniedIPs)}, ) if tt.res.wantErr { diff --git a/internal/api/grpc/server/server.go b/internal/api/grpc/server/server.go index a3719551e..230962d8c 100644 --- a/internal/api/grpc/server/server.go +++ b/internal/api/grpc/server/server.go @@ -16,7 +16,6 @@ import ( grpc_api "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore/record" @@ -69,7 +68,7 @@ func CreateServer( accessSvc *logstore.Service[*record.AccessLog], targetEncAlg crypto.EncryptionAlgorithm, translator *i18n.Translator, - deniedIPList []denylist.AddressChecker, + httpClient *http.Client, ) *grpc.Server { metricTypes := []metrics.MetricType{metrics.MetricTypeTotalCount, metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode} serverOptions := []grpc.ServerOption{ @@ -87,7 +86,7 @@ func CreateServer( middleware.AuthorizationInterceptor(verifier, systemAuthz, authConfig), middleware.TranslationHandler(), middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_ServiceDesc.ServiceName), - middleware.ExecutionHandler(targetEncAlg, queries.GetActiveSigningWebKey, deniedIPList), + middleware.ExecutionHandler(targetEncAlg, queries.GetActiveSigningWebKey, httpClient), middleware.ValidationHandler(), middleware.ServiceHandler(), middleware.ActivityInterceptor(), diff --git a/internal/api/http/client.go b/internal/api/http/client.go new file mode 100644 index 000000000..6235e1ae6 --- /dev/null +++ b/internal/api/http/client.go @@ -0,0 +1,131 @@ +package http + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/zitadel/zitadel/internal/denylist" +) + +type ClientConfig struct { + MaxBodySize int64 + Timeout time.Duration + MaxRedirects int + AllowHTTPSDowngrade bool + DenyList []denylist.AddressChecker +} + +// NewClient returns a new http.Client with the configured settings. +// The client is protected against DNS rebinding attacks, redirects, HTTPs downgrades, and response body size limits. +func (c *ClientConfig) NewClient() *http.Client { + baseTransport := denylist.NewHTTPTransport(c.DenyList) + + protectedTransport := &MaxBytesRoundTripper{ + Underlying: baseTransport, + MaxBytes: c.MaxBodySize, + } + + return &http.Client{ + Transport: protectedTransport, + Timeout: c.Timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= c.MaxRedirects { + return ErrTooManyRedirects + } + if !c.AllowHTTPSDowngrade && len(via) > 0 { + prev := via[len(via)-1] + if prev != nil && prev.URL != nil && req.URL != nil && + strings.EqualFold(prev.URL.Scheme, "https") && + !strings.EqualFold(req.URL.Scheme, "https") { + return ErrHTTPsDowngrade + } + } + return denylist.IsURLBlocked(c.DenyList, req.URL, nil) + }, + } +} + +// MergeDeprecatedDenylists merges the two deprecated (actions) denylists into the main denylist. +func (c *ClientConfig) MergeDeprecatedDenylists(actionsV1, actionsV2 []denylist.AddressChecker) { + c.DenyList = append(c.DenyList, actionsV1...) + c.DenyList = append(c.DenyList, actionsV2...) +} + +var ( + // ErrResponseTooLarge is returned when the response body exceeds the configured limit. + ErrResponseTooLarge = errors.New("response body exceeded maximum allowed size") + //ErrTooManyRedirects is returned when the number of redirects exceeds the configured limit. + ErrTooManyRedirects = errors.New("stopped after too many redirects") + // ErrHTTPsDowngrade is returned when the client attempts to downgrade to HTTP. + ErrHTTPsDowngrade = errors.New("redirect downgrade from https to http is not allowed") +) + +// MaxBytesRoundTripper wraps an existing RoundTripper to protect against OOM. +type MaxBytesRoundTripper struct { + Underlying http.RoundTripper + MaxBytes int64 +} + +func (m *MaxBytesRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + transport := m.Underlying + if transport == nil { + transport = http.DefaultTransport + } + + resp, err := transport.RoundTrip(req) + if err != nil { + return nil, err + } + + if resp.ContentLength > m.MaxBytes { + resp.Body.Close() + return nil, fmt.Errorf("%w: Content-Length is %d (limit %d)", ErrResponseTooLarge, resp.ContentLength, m.MaxBytes) + } + + resp.Body = &strictMaxBytesReader{ + limitReader: io.LimitReader(resp.Body, m.MaxBytes+1), // We initialize with +1 to detect overflows during Read + closer: resp.Body, + limit: m.MaxBytes, + } + + return resp, nil +} + +var _ http.RoundTripper = (*MaxBytesRoundTripper)(nil) + +// strictMaxBytesReader enforces a hard limit and returns an explicit error if exceeded. +type strictMaxBytesReader struct { + limitReader io.Reader + closer io.Closer + limit int64 + bytesRead int64 +} + +func (s *strictMaxBytesReader) Read(p []byte) (int, error) { + n, err := s.limitReader.Read(p) + s.bytesRead += int64(n) + + if s.bytesRead > s.limit { + // Because LimitReader stops at limit+1, excess is mathematically guaranteed to be 1 + safeN := n - 1 + if safeN < 0 { + safeN = 0 + } + + // This instantly slices off that single offending byte from the view of the caller + p = p[:safeN] + return safeN, ErrResponseTooLarge + } + + return n, err +} + +func (s *strictMaxBytesReader) Close() error { + return s.closer.Close() +} + +var _ io.ReadCloser = (*strictMaxBytesReader)(nil) diff --git a/internal/api/http/client_test.go b/internal/api/http/client_test.go new file mode 100644 index 000000000..03dff3611 --- /dev/null +++ b/internal/api/http/client_test.go @@ -0,0 +1,144 @@ +package http + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/denylist" +) + +func TestClientConfig_NewClient_Allowed(t *testing.T) { + t.Parallel() + + // Spin up a simple local test server to serve a dummy response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello world")) + })) + defer server.Close() + + // An empty denylist permits all safe destinations + cfg := &ClientConfig{ + MaxBodySize: 1024, + Timeout: 2 * time.Second, + MaxRedirects: 3, + DenyList: []denylist.AddressChecker{}, + } + + client := cfg.NewClient() + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + assert.NoError(t, err) + + resp, err := client.Do(req) + assert.NoError(t, err) + if resp != nil { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Equal(t, "hello world", string(body)) + } +} + +func TestClientConfig_Redirect_HTTPSDowngradeBlocked(t *testing.T) { + t.Parallel() + + cfg := &ClientConfig{ + MaxBodySize: 1024, + Timeout: 2 * time.Second, + MaxRedirects: 3, + AllowHTTPSDowngrade: false, // Explicitly disallow downgrades + DenyList: []denylist.AddressChecker{}, + } + + client := cfg.NewClient() + + // Mock a historical request chain moving from a secure schema to an unencrypted target + viaReq, _ := http.NewRequest(http.MethodGet, "https://secure-identity.com/oauth", nil) + currentReq, _ := http.NewRequest(http.MethodGet, "http://insecure-identity.com/callback", nil) + + err := client.CheckRedirect(currentReq, []*http.Request{viaReq}) + assert.ErrorIs(t, err, ErrHTTPsDowngrade) +} + +func TestMaxBytesRoundTripper_EnforcesCap(t *testing.T) { + t.Parallel() + + // Spin up a local server delivering an exact 10-byte payload response string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("0123456789")) + })) + defer server.Close() + + tests := []struct { + name string + maxBytes int64 + expectError error + }{ + { + name: "body cleanly within threshold limit", + maxBytes: 15, + expectError: nil, + }, + { + name: "body matches boundary size exactly", + maxBytes: 10, + expectError: nil, + }, + { + name: "body exceeds allowable limit", + maxBytes: 5, + expectError: ErrResponseTooLarge, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &ClientConfig{ + MaxBodySize: tt.maxBytes, + Timeout: 2 * time.Second, + MaxRedirects: 3, + DenyList: []denylist.AddressChecker{}, + } + + client := cfg.NewClient() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + resp, err := client.Do(req) + + if tt.expectError != nil { + // Depending on optimization, errors can trip directly on call or during chunk reading + if err != nil { + assert.ErrorIs(t, err, tt.expectError) + return + } + defer resp.Body.Close() + _, readErr := io.ReadAll(resp.Body) + assert.ErrorIs(t, readErr, tt.expectError) + } else { + assert.NoError(t, err) + defer resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + assert.NoError(t, readErr) + assert.Equal(t, 10, len(body)) + } + }) + } +} + +func TestClientConfig_MergeDeprecatedDenylists(t *testing.T) { + t.Parallel() + + cfg := &ClientConfig{DenyList: []denylist.AddressChecker{denylist.NewHostChecker("1.1.1.1")}} + + v1 := []denylist.AddressChecker{denylist.NewHostChecker("2.2.2.2")} + v2 := []denylist.AddressChecker{denylist.NewHostChecker("3.3.3.3")} + + cfg.MergeDeprecatedDenylists(v1, v2) + + assert.Len(t, cfg.DenyList, 3) +} diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index 6309549f6..d0d83e45b 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -127,6 +127,7 @@ func NewServer( fallbackLogger *slog.Logger, hashConfig crypto.HashConfig, federatedLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout], + httpClient *http.Client, ) (*Server, error) { opConfig, err := createOPConfig(config, defaultLogoutRedirectURI, cryptoKey) if err != nil { @@ -189,6 +190,7 @@ func NewServer( targetEncryptionAlgorithm: targetEncryptionAlgorithm, opCrypto: alg, assetAPIPrefix: assets.AssetAPI(), + httpClient: httpClient, } metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount} server.Handler = op.RegisterLegacyServer(server, diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index 4e9042ef9..e7ea89194 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -44,6 +44,7 @@ type Server struct { opCrypto op.Crypto assetAPIPrefix func(ctx context.Context) string + httpClient *http.Client } func endpoints(endpointConfig *EndpointConfig) op.Endpoints { diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 1dabf7d3b..ca3607f3d 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -424,7 +424,7 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user apiFields, action.Script, action.Name, - append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., + append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx, s.httpClient), actions.WithUUID(actionCtx))..., ) cancel() if err != nil { @@ -461,7 +461,7 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user UserGrants: qu.UserGrants, } - resp, err := execution.CallTargets(ctx, executionTargets, info, s.targetEncryptionAlgorithm, s.query.GetActiveSigningWebKey, s.command.ActionsV2DenyList) + resp, err := execution.CallTargets(ctx, executionTargets, info, s.targetEncryptionAlgorithm, s.query.GetActiveSigningWebKey, s.httpClient) if err != nil { return err } diff --git a/internal/api/saml/provider.go b/internal/api/saml/provider.go index 4fe93d3b3..b824cb9ae 100644 --- a/internal/api/saml/provider.go +++ b/internal/api/saml/provider.go @@ -48,6 +48,7 @@ func NewProvider( instanceHandler, userAgentCookie func(http.Handler) http.Handler, accessHandler *middleware.AccessInterceptor, + httpClient *http.Client, ) (*Provider, error) { metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount} @@ -63,6 +64,7 @@ func NewProvider( fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID), conf.DefaultLoginURLV2, ContextToIssuer, + httpClient, ) if err != nil { return nil, err @@ -125,6 +127,7 @@ func newStorage( defaultLoginURL string, defaultLoginURLV2 string, contextToIssuer func(context.Context) string, + actionHttpClient *http.Client, ) (*Storage, error) { return &Storage{ encAlg: encAlg, @@ -138,6 +141,7 @@ func newStorage( defaultLoginURL: defaultLoginURL, defaultLoginURLv2: defaultLoginURLV2, contextToIssuer: contextToIssuer, + httpClient: actionHttpClient, }, nil } diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index 53418ea2f..b20d8ba3e 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "strings" "time" @@ -66,6 +67,7 @@ type Storage struct { defaultLoginURL string defaultLoginURLv2 string contextToIssuer func(context.Context) string + httpClient *http.Client } func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*serviceprovider.ServiceProvider, error) { @@ -380,7 +382,7 @@ func (p *Storage) getCustomAttributes(ctx context.Context, user *query.User, use apiFields, action.Script, action.Name, - append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx))..., + append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx, p.httpClient))..., ) cancel() if err != nil { @@ -401,7 +403,7 @@ func (p *Storage) getCustomAttributes(ctx context.Context, user *query.User, use UserGrants: userGrants.UserGrants, } - resp, err := execution.CallTargets(ctx, executionTargets, info, p.targetEncAlg, p.query.GetActiveSigningWebKey, p.command.ActionsV2DenyList) + resp, err := execution.CallTargets(ctx, executionTargets, info, p.targetEncAlg, p.query.GetActiveSigningWebKey, p.httpClient) if err != nil { return nil, err } diff --git a/internal/api/ui/login/custom_action.go b/internal/api/ui/login/custom_action.go index 99f7835fb..29874196c 100644 --- a/internal/api/ui/login/custom_action.go +++ b/internal/api/ui/login/custom_action.go @@ -152,7 +152,7 @@ func (l *Login) runPostExternalAuthenticationActions( apiFields, a.Script, a.Name, - append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., + append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx, l.httpClient), actions.WithUUID(actionCtx))..., ) cancel() if err != nil { @@ -226,7 +226,7 @@ func (l *Login) runPostInternalAuthenticationActions( apiFields, a.Script, a.Name, - append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., + append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx, l.httpClient), actions.WithUUID(actionCtx))..., ) cancel() if err != nil { @@ -346,7 +346,7 @@ func (l *Login) runPreCreationActions( apiFields, a.Script, a.Name, - append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., + append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx, l.httpClient), actions.WithUUID(actionCtx))..., ) cancel() if err != nil { @@ -423,7 +423,7 @@ func (l *Login) runPostCreationActions( apiFields, a.Script, a.Name, - append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., + append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx, l.httpClient), actions.WithUUID(actionCtx))..., ) cancel() if err != nil { diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index 00a02b5d2..a2224da86 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -1053,6 +1053,7 @@ func (l *Login) googleProvider(ctx context.Context, identityProvider *query.IDPT secret, l.baseURL(ctx)+EndpointExternalLoginCallback, identityProvider.GoogleIDPTemplate.Scopes, + l.httpClient, ) } @@ -1079,6 +1080,7 @@ func (l *Login) oidcProvider(ctx context.Context, identityProvider *query.IDPTem l.baseURL(ctx)+EndpointExternalLoginCallback, identityProvider.OIDCIDPTemplate.Scopes, openid.DefaultMapper, + l.httpClient, opts..., ) } @@ -1092,6 +1094,7 @@ func (l *Login) jwtProvider(identityProvider *query.IDPTemplate) (*jwt.Provider, identityProvider.JWTIDPTemplate.HeaderName, identityProvider.JWTIDPTemplate.Audience, l.idpConfigAlg, + l.httpClient, ) } @@ -1123,6 +1126,7 @@ func (l *Login) oauthProvider(ctx context.Context, identityProvider *query.IDPTe func() idp.User { return oauth.NewUserMapper(identityProvider.OAuthIDPTemplate.IDAttribute) }, + l.httpClient, opts..., ) } @@ -1176,6 +1180,7 @@ func (l *Login) samlProvider(ctx context.Context, identityProvider *query.IDPTem identityProvider.Metadata, identityProvider.Certificate, key, + l.httpClient, opts..., ) } @@ -1198,6 +1203,7 @@ func (l *Login) azureProvider(ctx context.Context, identityProvider *query.IDPTe secret, l.baseURL(ctx)+EndpointExternalLoginCallback, identityProvider.AzureADIDPTemplate.Scopes, + l.httpClient, opts..., ) } @@ -1212,6 +1218,7 @@ func (l *Login) githubProvider(ctx context.Context, identityProvider *query.IDPT secret, l.baseURL(ctx)+EndpointExternalLoginCallback, identityProvider.GitHubIDPTemplate.Scopes, + l.httpClient, ) } @@ -1229,6 +1236,7 @@ func (l *Login) githubEnterpriseProvider(ctx context.Context, identityProvider * identityProvider.GitHubEnterpriseIDPTemplate.TokenEndpoint, identityProvider.GitHubEnterpriseIDPTemplate.UserEndpoint, identityProvider.GitHubIDPTemplate.Scopes, + l.httpClient, ) } @@ -1242,6 +1250,7 @@ func (l *Login) gitlabProvider(ctx context.Context, identityProvider *query.IDPT secret, l.baseURL(ctx)+EndpointExternalLoginCallback, identityProvider.GitLabIDPTemplate.Scopes, + l.httpClient, ) } @@ -1257,6 +1266,7 @@ func (l *Login) gitlabSelfHostedProvider(ctx context.Context, identityProvider * secret, l.baseURL(ctx)+EndpointExternalLoginCallback, identityProvider.GitLabSelfHostedIDPTemplate.Scopes, + l.httpClient, ) } @@ -1272,6 +1282,7 @@ func (l *Login) appleProvider(ctx context.Context, identityProvider *query.IDPTe l.baseURL(ctx)+EndpointExternalLoginCallbackFormPost, privateKey, identityProvider.AppleIDPTemplate.Scopes, + l.httpClient, ) } diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index ea60527ac..ee2f52654 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -44,6 +44,7 @@ type Login struct { idpConfigAlg crypto.EncryptionAlgorithm userCodeAlg crypto.EncryptionAlgorithm caches *Caches + httpClient *http.Client } type Config struct { @@ -198,6 +199,7 @@ func CreateLogin( csrfCookieKey []byte, cacheConnectors connector.Connectors, federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout], + httpClient *http.Client, ) (*Login, error) { login := &Login{ oidcAuthCallbackURL: oidcAuthCallbackURL, @@ -210,6 +212,7 @@ func CreateLogin( authRepo: authRepo, idpConfigAlg: idpConfigAlg, userCodeAlg: userCodeAlg, + httpClient: httpClient, } csrfInterceptor := createCSRFInterceptor(config.CSRFCookieName, csrfCookieKey, externalSecure, login.csrfErrorHandler()) cacheInterceptor := createCacheInterceptor(config.Cache.MaxAge, config.Cache.SharedMaxAge, assetCache) diff --git a/internal/command/action_v2_target.go b/internal/command/action_v2_target.go index 38f55f04e..3e9c62643 100644 --- a/internal/command/action_v2_target.go +++ b/internal/command/action_v2_target.go @@ -41,7 +41,7 @@ func (a *AddTarget) isValid(inputDenyList []denylist.AddressChecker, lookupFunc return zerrors.ThrowInvalidArgument(err, "COMMAND-1r2k6qo6wg", "Errors.Target.InvalidURL") } - if err := denylist.IsHostBlocked(inputDenyList, parsedURL, lookupFunc); err != nil { + if err := denylist.IsURLBlocked(inputDenyList, parsedURL, lookupFunc); err != nil { return zerrors.ThrowInvalidArgument(err, "COMMAND-NcJUKo", "Errors.Target.DeniedURL") } @@ -53,7 +53,7 @@ func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner return time.Time{}, zerrors.ThrowInvalidArgument(nil, "COMMAND-brml926e2d", "Errors.IDMissing") } - if err := add.isValid(c.ActionsV2DenyList, c.IPLookupFunction); err != nil { + if err := add.isValid(c.denyList, c.ipLookupFunction); err != nil { return time.Time{}, err } @@ -124,7 +124,7 @@ func (a *ChangeTarget) isValid(inputDenyList []denylist.AddressChecker, lookupFu if err != nil || *a.Endpoint == "" { return zerrors.ThrowInvalidArgument(err, "COMMAND-jsbaera7b6", "Errors.Target.InvalidURL") } - if err := denylist.IsHostBlocked(inputDenyList, parsedURL, lookupFunc); err != nil { + if err := denylist.IsURLBlocked(inputDenyList, parsedURL, lookupFunc); err != nil { return zerrors.ThrowInvalidArgument(err, "COMMAND-jKbbu2", "Errors.Target.DeniedURL") } } @@ -136,7 +136,7 @@ func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resou if resourceOwner == "" { return time.Time{}, zerrors.ThrowInvalidArgument(nil, "COMMAND-zqibgg0wwh", "Errors.IDMissing") } - if err := change.isValid(c.ActionsV2DenyList, c.IPLookupFunction); err != nil { + if err := change.isValid(c.denyList, c.ipLookupFunction); err != nil { return time.Time{}, err } existing, err := c.getTargetWriteModelByID(ctx, change.AggregateID, resourceOwner) diff --git a/internal/command/action_v2_target_test.go b/internal/command/action_v2_target_test.go index b9fa28dc5..259a5aa71 100644 --- a/internal/command/action_v2_target_test.go +++ b/internal/command/action_v2_target_test.go @@ -8,7 +8,6 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/denylist" @@ -23,8 +22,7 @@ import ( ) func TestCommands_AddTarget(t *testing.T) { - localhostAddrChecker, err := denylist.NewHostChecker("localhost") - require.NoError(t, err) + localhostAddrChecker := denylist.NewHostChecker("localhost") type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore @@ -305,7 +303,7 @@ func TestCommands_AddTarget(t *testing.T) { idGenerator: tt.fields.idGenerator, newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, defaultSecretGenerators: tt.fields.defaultSecretGenerators, - ActionsV2DenyList: tt.fields.denyList, + denyList: tt.fields.denyList, } _, err := c.AddTarget(tt.args.ctx, tt.args.add, tt.args.resourceOwner) if tt.res.err == nil { @@ -322,8 +320,7 @@ func TestCommands_AddTarget(t *testing.T) { } func TestCommands_ChangeTarget(t *testing.T) { - localhostAddrChecker, err := denylist.NewHostChecker("localhost") - require.NoError(t, err) + localhostAddrChecker := denylist.NewHostChecker("localhost") type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore @@ -647,8 +644,8 @@ func TestCommands_ChangeTarget(t *testing.T) { eventstore: tt.fields.eventstore(t), newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, defaultSecretGenerators: tt.fields.defaultSecretGenerators, - ActionsV2DenyList: tt.fields.denyList, - IPLookupFunction: tt.fields.lookupFunc, + denyList: tt.fields.denyList, + ipLookupFunction: tt.fields.lookupFunc, } _, err := c.ChangeTarget(tt.args.ctx, tt.args.change, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/command.go b/internal/command/command.go index 68bbbd2fc..d11d624e5 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -106,9 +106,9 @@ type Commands struct { // so the query and cache overhead can be completely eliminated. milestonesCompleted sync.Map - loginPaths LoginPaths - ActionsV2DenyList []denylist.AddressChecker - IPLookupFunction internal_net.IPLookupFunc + loginPaths LoginPaths + ipLookupFunction internal_net.IPLookupFunc + denyList []denylist.AddressChecker } //go:generate mockgen -package command -destination ./mock_login_paths.go . LoginPaths @@ -138,7 +138,7 @@ func StartCommands( defaultAccessTokenLifetime, defaultRefreshTokenLifetime, defaultRefreshTokenIdleLifetime time.Duration, defaultSecretGenerators *SecretGenerators, loginPaths LoginPaths, - actionsDeniedHostList []denylist.AddressChecker, + denyList []denylist.AddressChecker, ) (repo *Commands, err error) { if externalDomain == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified") @@ -162,6 +162,7 @@ func StartCommands( if err != nil { return nil, fmt.Errorf("caches: %w", err) } + ipLookupFunction := net.LookupIP repo = &Commands{ eventstore: es, static: staticStore, @@ -228,11 +229,11 @@ func StartCommands( WithHyphen: defaults.Multifactors.RecoveryCodes.WithHyphen, }, }, - GenerateDomain: domain.NewGeneratedInstanceDomain, - caches: caches, - loginPaths: loginPaths, - ActionsV2DenyList: actionsDeniedHostList, - IPLookupFunction: net.LookupIP, + GenerateDomain: domain.NewGeneratedInstanceDomain, + caches: caches, + loginPaths: loginPaths, + ipLookupFunction: ipLookupFunction, + denyList: denyList, } if defaultSecretGenerators != nil && defaultSecretGenerators.ClientSecret != nil { diff --git a/internal/command/idp_intent.go b/internal/command/idp_intent.go index 8b56e02b2..93b6d3530 100644 --- a/internal/command/idp_intent.go +++ b/internal/command/idp_intent.go @@ -95,11 +95,12 @@ func (c *Commands) GetProvider(ctx context.Context, idpID string, idpCallback st return nil, err } if writeModel.IDPType != domain.IDPTypeSAML { - return writeModel.ToProvider(idpCallback, c.idpConfigEncryption) + return writeModel.ToProvider(idpCallback, c.idpConfigEncryption, c.httpClient) } return writeModel.ToSAMLProvider( samlRootURL, c.idpConfigEncryption, + c.httpClient, func(ctx context.Context, intentID string) (*samlsp.TrackedRequest, error) { intent, err := c.GetActiveIntent(ctx, intentID) if err != nil { diff --git a/internal/command/idp_model.go b/internal/command/idp_model.go index 1dd389fe6..a3630cf46 100644 --- a/internal/command/idp_model.go +++ b/internal/command/idp_model.go @@ -164,7 +164,7 @@ func (wm *OAuthIDPWriteModel) NewChanges( return changes, nil } -func (wm *OAuthIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *OAuthIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) if err != nil { return nil, err @@ -202,6 +202,7 @@ func (wm *OAuthIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encry func() providers.User { return oauth.NewUserMapper(wm.IDAttribute) }, + httpClient, opts..., ) } @@ -380,7 +381,7 @@ func (wm *OIDCIDPWriteModel) reduceOIDCConfigChangedEvent(e *idpconfig.OIDCConfi } } -func (wm *OIDCIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *OIDCIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) if err != nil { return nil, err @@ -413,6 +414,7 @@ func (wm *OIDCIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encryp callbackURL, wm.Scopes, oidc.DefaultMapper, + httpClient, opts..., ) } @@ -572,7 +574,7 @@ func (wm *JWTIDPWriteModel) reduceJWTConfigChangedEvent(e *idpconfig.JWTConfigCh } } -func (wm *JWTIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *JWTIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { opts := make([]jwt.ProviderOpts, 0) if wm.IsCreationAllowed { opts = append(opts, jwt.WithCreationAllowed()) @@ -594,6 +596,7 @@ func (wm *JWTIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encrypt wm.HeaderName, wm.Audience, idpAlg, + httpClient, opts..., ) } @@ -708,7 +711,7 @@ func (wm *AzureADIDPWriteModel) NewChanges( } return changes, nil } -func (wm *AzureADIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *AzureADIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) if err != nil { return nil, err @@ -742,6 +745,7 @@ func (wm *AzureADIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Enc secret, callbackURL, wm.Scopes, + httpClient, opts..., ) } @@ -836,7 +840,7 @@ func (wm *GitHubIDPWriteModel) NewChanges( } return changes, nil } -func (wm *GitHubIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *GitHubIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) if err != nil { return nil, err @@ -859,6 +863,7 @@ func (wm *GitHubIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encr secret, callbackURL, wm.Scopes, + httpClient, oauthOpts..., ) } @@ -980,7 +985,7 @@ func (wm *GitHubEnterpriseIDPWriteModel) NewChanges( return changes, nil } -func (wm *GitHubEnterpriseIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *GitHubEnterpriseIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) if err != nil { return nil, err @@ -1007,6 +1012,7 @@ func (wm *GitHubEnterpriseIDPWriteModel) ToProvider(callbackURL string, idpAlg c wm.TokenEndpoint, wm.UserEndpoint, wm.Scopes, + httpClient, oauthOpts..., ) } @@ -1102,7 +1108,7 @@ func (wm *GitLabIDPWriteModel) NewChanges( return changes, nil } -func (wm *GitLabIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *GitLabIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) if err != nil { return nil, err @@ -1125,6 +1131,7 @@ func (wm *GitLabIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encr secret, callbackURL, wm.Scopes, + httpClient, opts..., ) } @@ -1228,7 +1235,7 @@ func (wm *GitLabSelfHostedIDPWriteModel) NewChanges( return changes, nil } -func (wm *GitLabSelfHostedIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *GitLabSelfHostedIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) if err != nil { return nil, err @@ -1253,6 +1260,7 @@ func (wm *GitLabSelfHostedIDPWriteModel) ToProvider(callbackURL string, idpAlg c secret, callbackURL, wm.Scopes, + httpClient, opts..., ) } @@ -1350,7 +1358,7 @@ func (wm *GoogleIDPWriteModel) NewChanges( return changes, nil } -func (wm *GoogleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *GoogleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { errorHandler := func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, state string) { logging.Errorf("token exchanged failed: %s - %s (state: %s)", errorType, errorType, state) rp.DefaultErrorHandler(w, r, errorType, errorDesc, state) @@ -1378,6 +1386,7 @@ func (wm *GoogleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encr secret, callbackURL, wm.Scopes, + httpClient, opts..., ) } @@ -1552,7 +1561,7 @@ func (wm *LDAPIDPWriteModel) NewChanges( return changes, nil } -func (wm *LDAPIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *LDAPIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { password, err := crypto.DecryptString(wm.BindPassword, idpAlg) if err != nil { return nil, err @@ -1731,7 +1740,7 @@ func (wm *AppleIDPWriteModel) NewChanges( return changes, nil } -func (wm *AppleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *AppleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) { privateKey, err := crypto.Decrypt(wm.PrivateKey, idpAlg) if err != nil { return nil, err @@ -1756,6 +1765,7 @@ func (wm *AppleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encry callbackURL, privateKey, wm.Scopes, + httpClient, opts..., ) } @@ -1902,7 +1912,13 @@ func (wm *SAMLIDPWriteModel) NewChanges( return changes, nil } -func (wm *SAMLIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, getRequest requesttracker.GetRequest, addRequest requesttracker.AddRequest) (providers.Provider, error) { +func (wm *SAMLIDPWriteModel) ToProvider( + callbackURL string, + idpAlg crypto.EncryptionAlgorithm, + httpClient *http.Client, + getRequest requesttracker.GetRequest, + addRequest requesttracker.AddRequest, +) (providers.Provider, error) { key, err := crypto.Decrypt(wm.Key, idpAlg) if err != nil { return nil, err @@ -1949,6 +1965,7 @@ func (wm *SAMLIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encryp wm.Metadata, wm.Certificate, key, + httpClient, opts..., ) } @@ -2202,13 +2219,13 @@ func (wm *IDPTypeWriteModel) Query() *eventstore.SearchQueryBuilder { type IDP interface { eventstore.QueryReducer - ToProvider(string, crypto.EncryptionAlgorithm) (providers.Provider, error) + ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, httpClient *http.Client) (providers.Provider, error) GetProviderOptions() idp.Options } type SAMLIDP interface { eventstore.QueryReducer - ToProvider(string, crypto.EncryptionAlgorithm, requesttracker.GetRequest, requesttracker.AddRequest) (providers.Provider, error) + ToProvider(string, crypto.EncryptionAlgorithm, *http.Client, requesttracker.GetRequest, requesttracker.AddRequest) (providers.Provider, error) GetProviderOptions() idp.Options } @@ -2318,11 +2335,15 @@ func (wm *AllIDPWriteModel) AppendEvents(events ...eventstore.Event) { wm.samlModel.AppendEvents(events...) } -func (wm *AllIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { +func (wm *AllIDPWriteModel) ToProvider( + callbackURL string, + idpAlg crypto.EncryptionAlgorithm, + httpClient *http.Client, +) (providers.Provider, error) { if wm.model == nil { return nil, zerrors.ThrowInternal(nil, "COMMAND-afvf0gc9sa", "ErrorsIDPConfig.NotExisting") } - return wm.model.ToProvider(callbackURL, idpAlg) + return wm.model.ToProvider(callbackURL, idpAlg, httpClient) } func (wm *AllIDPWriteModel) GetProviderOptions() idp.Options { @@ -2332,11 +2353,23 @@ func (wm *AllIDPWriteModel) GetProviderOptions() idp.Options { return wm.samlModel.GetProviderOptions() } -func (wm *AllIDPWriteModel) ToSAMLProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm, getRequest requesttracker.GetRequest, addRequest requesttracker.AddRequest) (providers.Provider, error) { +func (wm *AllIDPWriteModel) ToSAMLProvider( + callbackURL string, + idpAlg crypto.EncryptionAlgorithm, + httpClient *http.Client, + getRequest requesttracker.GetRequest, + addRequest requesttracker.AddRequest, +) (providers.Provider, error) { if wm.samlModel == nil { return nil, zerrors.ThrowInternal(nil, "COMMAND-csi30hdscv", "ErrorsIDPConfig.NotExisting") } - return wm.samlModel.ToProvider(callbackURL, idpAlg, getRequest, addRequest) + return wm.samlModel.ToProvider( + callbackURL, + idpAlg, + httpClient, + getRequest, + addRequest, + ) } type ZitadelIDPWriteModel struct { diff --git a/internal/command/idp_model_test.go b/internal/command/idp_model_test.go index d4deba371..1c55a95fb 100644 --- a/internal/command/idp_model_test.go +++ b/internal/command/idp_model_test.go @@ -377,7 +377,7 @@ func TestOAuthIDPWriteModel_ToProvider_WithPKCE(t *testing.T) { UsePKCE: true, } - provider, err := wm.ToProvider("https://zitadel.example.com/idps/callback", plainTextEncryption{}) + provider, err := wm.ToProvider("https://zitadel.example.com/idps/callback", plainTextEncryption{}, http.DefaultClient) require.NoError(t, err) assertProviderUsesPKCE(t, provider) @@ -417,7 +417,7 @@ func TestOIDCIDPWriteModel_ToProvider_WithPKCE(t *testing.T) { UsePKCE: true, } - provider, err := wm.ToProvider("https://zitadel.example.com/idps/callback", plainTextEncryption{}) + provider, err := wm.ToProvider("https://zitadel.example.com/idps/callback", plainTextEncryption{}, http.DefaultClient) require.NoError(t, err) require.True(t, discoveryRequested.Load(), "expected OIDC discovery to be requested") diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 7666f910a..d1daa5ad1 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -2,6 +2,7 @@ package command import ( "context" + "net/url" "strings" "time" @@ -9,6 +10,7 @@ import ( http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" project_repo "github.com/zitadel/zitadel/internal/repository/project" @@ -207,6 +209,12 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain if err != nil { return nil, err } + + backchannelLogoutURI, err := c.validateBackchannelLogoutURI(oidcApp) + if err != nil { + return nil, err + } + events = append(events, project_repo.NewOIDCConfigAddedEvent(ctx, projectAgg, gu.Value(oidcApp.OIDCVersion), @@ -227,7 +235,7 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain gu.Value(oidcApp.ClockSkew), trimStringSliceWhiteSpaces(oidcApp.AdditionalOrigins), gu.Value(oidcApp.SkipNativeAppSuccessPage), - strings.TrimSpace(gu.Value(oidcApp.BackChannelLogoutURI)), + backchannelLogoutURI, gu.Value(oidcApp.LoginVersion), strings.TrimSpace(gu.Value(oidcApp.LoginBaseURI)), )) @@ -252,6 +260,20 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain return result, nil } +func (c *Commands) validateBackchannelLogoutURI(oidcApp *domain.OIDCApp) (string, error) { + backchannelLogoutURL := strings.TrimSpace(gu.Value(oidcApp.BackChannelLogoutURI)) + if backchannelLogoutURL != "" { + url, err := url.Parse(backchannelLogoutURL) + if err != nil { + return "", zerrors.ThrowInvalidArgument(err, "PROJECT-iZL2Ac", "Errors.Project.App.Invalid.BackchannelLogoutURL") + } + if err := denylist.IsURLBlocked(c.denyList, url, c.ipLookupFunction); err != nil { + return "", zerrors.ThrowInvalidArgument(err, "PROJECT-msNebo", "Errors.Project.App.Blocked.BackchannelLogoutURL") + } + } + return backchannelLogoutURL, nil +} + func (c *Commands) UpdateOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { if !oidc.IsValid() || oidc.AppID == "" || oidc.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5m9fs", "Errors.Project.App.OIDCConfigInvalid") @@ -277,7 +299,11 @@ func (c *Commands) UpdateOIDCApplication(ctx context.Context, oidc *domain.OIDCA projectAgg := ProjectAggregateFromWriteModel(&existingOIDC.WriteModel) var backChannelLogout, loginBaseURI *string if oidc.BackChannelLogoutURI != nil { - backChannelLogout = gu.Ptr(strings.TrimSpace(*oidc.BackChannelLogoutURI)) + bcl, err := c.validateBackchannelLogoutURI(oidc) + if err != nil { + return nil, err + } + backChannelLogout = gu.Ptr(bcl) } if oidc.LoginBaseURI != nil { diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index 2acb096e3..be4ee73d7 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -2,6 +2,7 @@ package command import ( "context" + "net" "testing" "time" @@ -10,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -407,6 +409,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator + denyList []denylist.AddressChecker } type args struct { ctx context.Context @@ -816,6 +819,83 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { }, }, }, + { + name: "backchannel logout URI invalid, invalid argument error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingUnspecified), + ), + ), + expectFilter(), + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + oidcApp: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppName: "app", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + BackChannelLogoutURI: gu.Ptr("http://example.com\x00"), + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "backchannel logout URI on denylist, invalid argument error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingUnspecified), + ), + ), + expectFilter(), + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"), + denyList: []denylist.AddressChecker{denylist.NewHostChecker("blocked.example.com")}, + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + oidcApp: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppName: "app", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + BackChannelLogoutURI: gu.Ptr("https://blocked.example.com/logout"), + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -828,6 +908,10 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { ClientSecret: emptyConfig, }, checkPermission: newMockPermissionCheckAllowed(), + ipLookupFunction: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("1.2.3.4")}, nil + }, + denyList: tt.fields.denyList, } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) @@ -848,6 +932,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { t.Parallel() type fields struct { eventstore func(*testing.T) *eventstore.Eventstore + denyList []denylist.AddressChecker } type args struct { ctx context.Context @@ -1195,13 +1280,138 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, }, }, + { + name: "backchannel logout URI invalid, invalid argument error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + ), + ), + eventFromEventPusher( + project.NewOIDCConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + domain.OIDCVersionV1, + "app1", + "client1", + "", + nil, + []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + domain.OIDCApplicationTypeWeb, + domain.OIDCAuthMethodTypeNone, + nil, + false, + domain.OIDCTokenTypeBearer, + false, + false, + false, + 0, + nil, + false, + "", + domain.LoginVersionUnspecified, + "", + ), + ), + ), + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + oidcApp: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppID: "app1", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone), + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + BackChannelLogoutURI: gu.Ptr("http://example.com\x00"), + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "backchannel logout URI on denylist, invalid argument error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + ), + ), + eventFromEventPusher( + project.NewOIDCConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + domain.OIDCVersionV1, + "app1", + "client1", + "", + nil, + []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + domain.OIDCApplicationTypeWeb, + domain.OIDCAuthMethodTypeNone, + nil, + false, + domain.OIDCTokenTypeBearer, + false, + false, + false, + 0, + nil, + false, + "", + domain.LoginVersionUnspecified, + "", + ), + ), + ), + expectFilter(), + ), + denyList: []denylist.AddressChecker{denylist.NewHostChecker("blocked.example.com")}, + }, + args: args{ + ctx: context.Background(), + oidcApp: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppID: "app1", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone), + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + BackChannelLogoutURI: gu.Ptr("https://blocked.example.com/logout"), + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // t.Parallel() + t.Parallel() r := &Commands{ eventstore: tt.fields.eventstore(t), checkPermission: newMockPermissionCheckAllowed(), + ipLookupFunction: func(host string) ([]net.IP, error) { + return []net.IP{net.ParseIP("1.2.3.4")}, nil + }, + denyList: tt.fields.denyList, } got, err := r.UpdateOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/sms_config.go b/internal/command/sms_config.go index 3fa0b2b32..cb6285495 100644 --- a/internal/command/sms_config.go +++ b/internal/command/sms_config.go @@ -170,6 +170,11 @@ func (c *Commands) AddSMSConfigHTTP(ctx context.Context, config *AddSMSHTTP) (er return err } } + + if err := c.validateNotificationWebhookEndpoint(config.Endpoint); err != nil { + return err + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, config.ResourceOwner, config.ID) if err != nil { return err @@ -217,6 +222,13 @@ func (c *Commands) ChangeSMSConfigHTTP(ctx context.Context, config *ChangeSMSHTT if config.ID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-phyb2e4Kll", "Errors.IDMissing") } + + if config.Endpoint != nil { + if err := c.validateNotificationWebhookEndpoint(*config.Endpoint); err != nil { + return err + } + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, config.ResourceOwner, config.ID) if err != nil { return err diff --git a/internal/command/sms_config_test.go b/internal/command/sms_config_test.go index e1a81d957..da447b898 100644 --- a/internal/command/sms_config_test.go +++ b/internal/command/sms_config_test.go @@ -3,6 +3,7 @@ package command import ( "context" "errors" + "net" "testing" "time" @@ -11,6 +12,7 @@ import ( "go.uber.org/mock/gomock" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" @@ -312,6 +314,8 @@ func TestCommandSide_AddSMSConfigHTTP(t *testing.T) { newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc defaultSecretGenerators *SecretGenerators alg crypto.EncryptionAlgorithm + denyList []denylist.AddressChecker + ipLookupFunc func(string) ([]net.IP, error) } type args struct { ctx context.Context @@ -366,6 +370,9 @@ func TestCommandSide_AddSMSConfigHTTP(t *testing.T) { idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "providerid"), newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), defaultSecretGenerators: &SecretGenerators{}, + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, }, args: args{ ctx: context.Background(), @@ -381,6 +388,30 @@ func TestCommandSide_AddSMSConfigHTTP(t *testing.T) { }, }, }, + { + name: "add sms config http, blocked endpoint", + fields: fields{ + eventstore: expectEventstore(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "providerid"), + denyList: []denylist.AddressChecker{denylist.NewHostChecker("blocked.example.com")}, + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, + }, + args: args{ + ctx: context.Background(), + http: &AddSMSHTTP{ + ResourceOwner: "INSTANCE", + Description: "description", + Endpoint: "https://blocked.example.com/hook", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-Uf0294nKls", "")) + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -390,6 +421,8 @@ func TestCommandSide_AddSMSConfigHTTP(t *testing.T) { newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, defaultSecretGenerators: tt.fields.defaultSecretGenerators, smsEncryption: tt.fields.alg, + denyList: tt.fields.denyList, + ipLookupFunction: tt.fields.ipLookupFunc, } err := r.AddSMSConfigHTTP(tt.args.ctx, tt.args.http) if tt.res.err == nil { @@ -410,6 +443,8 @@ func TestCommandSide_ChangeSMSConfigHTTP(t *testing.T) { eventstore func(*testing.T) *eventstore.Eventstore newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc defaultSecretGenerators *SecretGenerators + denyList []denylist.AddressChecker + ipLookupFunc func(string) ([]net.IP, error) } type args struct { ctx context.Context @@ -499,6 +534,9 @@ func TestCommandSide_ChangeSMSConfigHTTP(t *testing.T) { ), ), ), + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, }, args: args{ ctx: context.Background(), @@ -552,6 +590,9 @@ func TestCommandSide_ChangeSMSConfigHTTP(t *testing.T) { ), newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("87654321", time.Hour), defaultSecretGenerators: &SecretGenerators{}, + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, }, args: args{ ctx: context.Background(), @@ -569,6 +610,29 @@ func TestCommandSide_ChangeSMSConfigHTTP(t *testing.T) { }, }, }, + { + name: "change sms config, blocked endpoint", + fields: fields{ + eventstore: expectEventstore(), + denyList: []denylist.AddressChecker{denylist.NewHostChecker("blocked.example.com")}, + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, + }, + args: args{ + ctx: context.Background(), + http: &ChangeSMSHTTP{ + ResourceOwner: "INSTANCE", + ID: "providerid", + Endpoint: gu.Ptr("https://blocked.example.com/hook"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-Uf0294nKls", "")) + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -576,6 +640,8 @@ func TestCommandSide_ChangeSMSConfigHTTP(t *testing.T) { eventstore: tt.fields.eventstore(t), newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, defaultSecretGenerators: tt.fields.defaultSecretGenerators, + denyList: tt.fields.denyList, + ipLookupFunction: tt.fields.ipLookupFunc, } err := r.ChangeSMSConfigHTTP(tt.args.ctx, tt.args.http) if tt.res.err == nil { diff --git a/internal/command/smtp.go b/internal/command/smtp.go index eace3ba00..dacd8445d 100644 --- a/internal/command/smtp.go +++ b/internal/command/smtp.go @@ -3,11 +3,13 @@ package command import ( "context" "net" + "net/url" "strings" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/notification/channels/smtp" @@ -304,6 +306,10 @@ func (c *Commands) AddSMTPConfigHTTP(ctx context.Context, config *AddSMTPConfigH } } + if err := c.validateNotificationWebhookEndpoint(config.Endpoint); err != nil { + return err + } + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, config.ResourceOwner, config.ID, "") if err != nil { return err @@ -349,6 +355,10 @@ func (c *Commands) ChangeSMTPConfigHTTP(ctx context.Context, config *ChangeSMTPC return zerrors.ThrowInvalidArgument(nil, "COMMAND-2MHkV8ObWo", "Errors.IDMissing") } + if err := c.validateNotificationWebhookEndpoint(config.Endpoint); err != nil { + return err + } + smtpConfigWriteModel, err := c.getSMTPConfig(ctx, config.ResourceOwner, config.ID, "") if err != nil { return err @@ -698,6 +708,20 @@ func (c *Commands) prepareAddAndActivateSMTPConfig(a *instance.Aggregate, descri } } +func (c *Commands) validateNotificationWebhookEndpoint(endpoint string) error { + if endpoint == "" { + return nil + } + uri, err := url.Parse(endpoint) + if err != nil { + return zerrors.ThrowInvalidArgument(err, "COMMAND-Sz84nFo3aK", "Errors.Invalid.Argument") + } + if err := denylist.IsURLBlocked(c.denyList, uri, c.ipLookupFunction); err != nil { + return zerrors.ThrowInvalidArgument(err, "COMMAND-Uf0294nKls", "Errors.NotificationProvider.Blocked.Endpoint") + } + return nil +} + func getSMTPConfigWriteModel(ctx context.Context, filter preparation.FilterToQueryReducer, id, domain string) (_ *IAMSMTPConfigWriteModel, err error) { writeModel := NewIAMSMTPConfigWriteModel(authz.GetInstance(ctx).InstanceID(), id, domain) events, err := filter(ctx, writeModel.Query()) diff --git a/internal/command/smtp_test.go b/internal/command/smtp_test.go index 7200660d2..c0380dbae 100644 --- a/internal/command/smtp_test.go +++ b/internal/command/smtp_test.go @@ -3,6 +3,7 @@ package command import ( "context" "errors" + "net" "testing" "time" @@ -11,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" @@ -1335,6 +1337,8 @@ func TestCommandSide_AddSMTPConfigHTTP(t *testing.T) { newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc defaultSecretGenerators *SecretGenerators idGenerator id.Generator + denyList []denylist.AddressChecker + ipLookupFunc func(string) ([]net.IP, error) } type args struct { http *AddSMTPConfigHTTP @@ -1387,6 +1391,9 @@ func TestCommandSide_AddSMTPConfigHTTP(t *testing.T) { idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "configid"), newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), defaultSecretGenerators: &SecretGenerators{}, + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, }, args: args{ http: &AddSMTPConfigHTTP{ @@ -1401,6 +1408,29 @@ func TestCommandSide_AddSMTPConfigHTTP(t *testing.T) { }, }, }, + { + name: "add smtp config, blocked endpoint", + fields: fields{ + eventstore: expectEventstore(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "configid"), + denyList: []denylist.AddressChecker{denylist.NewHostChecker("blocked.example.com")}, + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, + }, + args: args{ + http: &AddSMTPConfigHTTP{ + ResourceOwner: "INSTANCE", + Description: "test", + Endpoint: "https://blocked.example.com/hook", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-Uf0294nKls", "")) + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1409,6 +1439,8 @@ func TestCommandSide_AddSMTPConfigHTTP(t *testing.T) { idGenerator: tt.fields.idGenerator, newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, defaultSecretGenerators: tt.fields.defaultSecretGenerators, + denyList: tt.fields.denyList, + ipLookupFunction: tt.fields.ipLookupFunc, } err := r.AddSMTPConfigHTTP(context.Background(), tt.args.http) if tt.res.err == nil { @@ -1430,6 +1462,8 @@ func TestCommandSide_ChangeSMTPConfigHTTP(t *testing.T) { eventstore func(t *testing.T) *eventstore.Eventstore newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc defaultSecretGenerators *SecretGenerators + denyList []denylist.AddressChecker + ipLookupFunc func(string) ([]net.IP, error) } type args struct { http *ChangeSMTPConfigHTTP @@ -1479,6 +1513,9 @@ func TestCommandSide_ChangeSMTPConfigHTTP(t *testing.T) { eventstore: expectEventstore( expectFilter(), ), + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, }, args: args{ http: &ChangeSMTPConfigHTTP{ @@ -1516,6 +1553,9 @@ func TestCommandSide_ChangeSMTPConfigHTTP(t *testing.T) { ), ), ), + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, }, args: args{ http: &ChangeSMTPConfigHTTP{ @@ -1569,6 +1609,9 @@ func TestCommandSide_ChangeSMTPConfigHTTP(t *testing.T) { ), newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("87654321", time.Hour), defaultSecretGenerators: &SecretGenerators{}, + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, }, args: args{ http: &ChangeSMTPConfigHTTP{ @@ -1585,6 +1628,28 @@ func TestCommandSide_ChangeSMTPConfigHTTP(t *testing.T) { }, }, }, + { + name: "change smtp config, blocked endpoint", + fields: fields{ + eventstore: expectEventstore(), + denyList: []denylist.AddressChecker{denylist.NewHostChecker("blocked.example.com")}, + ipLookupFunc: func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + }, + }, + args: args{ + http: &ChangeSMTPConfigHTTP{ + ResourceOwner: "INSTANCE", + ID: "configid", + Endpoint: "https://blocked.example.com/hook", + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-Uf0294nKls", "")) + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1592,6 +1657,8 @@ func TestCommandSide_ChangeSMTPConfigHTTP(t *testing.T) { eventstore: tt.fields.eventstore(t), newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, defaultSecretGenerators: tt.fields.defaultSecretGenerators, + denyList: tt.fields.denyList, + ipLookupFunction: tt.fields.ipLookupFunc, } err := r.ChangeSMTPConfigHTTP(context.Background(), tt.args.http) if tt.res.err == nil { @@ -1607,6 +1674,66 @@ func TestCommandSide_ChangeSMTPConfigHTTP(t *testing.T) { } } +func TestCommands_validateNotificationWebhookEndpoint(t *testing.T) { + t.Parallel() + + blockedLookup := func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.1")}, nil + } + + wantBlockedErr := zerrors.ThrowInvalidArgument(nil, "COMMAND-Uf0294nKls", "") + + tests := []struct { + name string + denyList []denylist.AddressChecker + endpoint string + wantErr error + }{ + { + name: "empty endpoint skips denylist check", + endpoint: "", + }, + { + name: "endpoint not on denylist, ok", + endpoint: "https://external.example.com/hook", + denyList: []denylist.AddressChecker{denylist.NewHostChecker("blocked.example.com")}, + }, + { + name: "no denylist configured, ok", + endpoint: "https://anything.example.com/hook", + }, + { + name: "endpoint domain on denylist, blocked", + endpoint: "https://blocked.example.com/hook", + denyList: []denylist.AddressChecker{denylist.NewHostChecker("blocked.example.com")}, + wantErr: wantBlockedErr, + }, + { + name: "endpoint resolves to blocked IP range, blocked", + endpoint: "https://internal.host/hook", + denyList: []denylist.AddressChecker{denylist.NewHostChecker("10.0.0.0/8")}, + wantErr: wantBlockedErr, + }, + { + name: "localhost IP directly in URL is blocked", + endpoint: "http://127.0.0.1/hook", + denyList: []denylist.AddressChecker{denylist.NewHostChecker("127.0.0.0/8")}, + wantErr: wantBlockedErr, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ + denyList: tt.denyList, + ipLookupFunction: blockedLookup, + } + err := r.validateNotificationWebhookEndpoint(tt.endpoint) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + func TestCommandSide_ActivateSMTPConfig(t *testing.T) { type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore diff --git a/internal/denylist/denylist.go b/internal/denylist/denylist.go index a469ad145..602ff2349 100644 --- a/internal/denylist/denylist.go +++ b/internal/denylist/denylist.go @@ -14,10 +14,7 @@ type AddressChecker interface { func ParseDenyList(inputList []string) ([]AddressChecker, error) { var toReturn []AddressChecker for _, input := range inputList { - parsed, parseErr := NewHostChecker(input) - if parseErr != nil { - return nil, parseErr - } + parsed := NewHostChecker(input) if parsed != nil { toReturn = append(toReturn, parsed) } diff --git a/internal/denylist/denylist_test.go b/internal/denylist/denylist_test.go new file mode 100644 index 000000000..4ad1a41f3 --- /dev/null +++ b/internal/denylist/denylist_test.go @@ -0,0 +1,45 @@ +package denylist + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseDenyList(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputIPList []string + expectedAddressCheckers int + expectedError error + }{ + { + testName: "when empty list should return no error and no checker list", + }, + { + testName: "list with a non-ip should return no error and no checker list", + inputIPList: []string{"127.0.0.1", "localhost", "not an ip"}, + expectedAddressCheckers: 3, + }, + { + testName: "parseable IP list should return no error, non-zero hash and non-empty checker list", + inputIPList: []string{"127.0.0.1", "localhost", "1.1.1.1"}, + expectedAddressCheckers: 3, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Test + checkers, err := ParseDenyList(tc.inputIPList) + + // Verify + assert.Len(t, checkers, tc.expectedAddressCheckers) + assert.ErrorIs(t, err, tc.expectedError) + }) + } +} diff --git a/internal/denylist/host_checker.go b/internal/denylist/host_checker.go index 708ad1479..bbb43e058 100644 --- a/internal/denylist/host_checker.go +++ b/internal/denylist/host_checker.go @@ -3,6 +3,7 @@ package denylist import ( "net" "net/url" + "strings" internal_net "github.com/zitadel/zitadel/internal/net" ) @@ -15,23 +16,30 @@ type HostChecker struct { Domain string } -func NewHostChecker(entry string) (AddressChecker, error) { +// NewHostChecker returns an [AddressChecker]. +// +// If the input string is neither a network, nor an IP address, it is considered a domain and saved as such +func NewHostChecker(entry string) AddressChecker { if entry == "" { - return nil, nil + return nil } _, network, err := net.ParseCIDR(entry) if err == nil { - return &HostChecker{Net: network}, nil + return &HostChecker{Net: network} } if ip := net.ParseIP(entry); ip != nil { - return &HostChecker{IP: ip}, nil + return &HostChecker{IP: ip} } - return &HostChecker{Domain: entry}, nil + return &HostChecker{Domain: entry} } +// IsDenied checks if the address or one of the IPs is blocked by the [HostChecker]. +// The address is checked using string comparison. +// If either is blocked, an [AddressDeniedError] will be returned. +// If either is not blocked, it will return nil. func (c *HostChecker) IsDenied(ips []net.IP, address string) error { // if the address matches the domain, no additional checks as needed - if c.Domain == address { + if c.Domain != "" && strings.EqualFold(c.Domain, address) { return NewAddressDeniedError(c.Domain) } // otherwise we need to check on ips (incl. the resolved ips of the host) @@ -46,20 +54,9 @@ func (c *HostChecker) IsDenied(ips []net.IP, address string) error { return nil } -// IsHostBlocked checks address against denyList. If a match is found, an [AddressDeniedError] will be returned -// Takes an input lookupFunc to convert address to a list of IP addresses. -// If lookupFunc is nil, it defaults to [net.LookupIP] -func IsHostBlocked(denyList []AddressChecker, address *url.URL, lookupFunc internal_net.IPLookupFunc) error { - if lookupFunc == nil { - lookupFunc = net.LookupIP - } - - host := address.Hostname() - ips, err := internal_net.HostnameToIPList(host, lookupFunc) - if err != nil { - return err - } - +// IsHostBlocked checks address against denyList. If a match is found, an [AddressDeniedError] will be returned. +// If no match is found, it will return nil. +func IsHostBlocked(denyList []AddressChecker, host string, ips ...net.IP) error { for _, denied := range denyList { if err := denied.IsDenied(ips, host); err != nil { return err @@ -67,3 +64,26 @@ func IsHostBlocked(denyList []AddressChecker, address *url.URL, lookupFunc inter } return nil } + +// IsHostnameBlocked checks a hostname against the denyList. If a match is found, an [AddressDeniedError] will be returned +// It takes an input lookupFunc to convert the hostname to a list of IP addresses. +// If lookupFunc is nil, it defaults to [net.LookupIP] +func IsHostnameBlocked(denyList []AddressChecker, hostname string, lookupFunc internal_net.IPLookupFunc) error { + if lookupFunc == nil { + lookupFunc = net.LookupIP + } + + ips, err := internal_net.HostnameToIPList(hostname, lookupFunc) + if err != nil { + return err + } + + return IsHostBlocked(denyList, hostname, ips...) +} + +// IsURLBlocked checks a URL against denyList. If a match is found, an [AddressDeniedError] will be returned +// It takes an input lookupFunc to convert the URL's hostname to a list of IP addresses. +// If lookupFunc is nil, it defaults to [net.LookupIP] +func IsURLBlocked(denyList []AddressChecker, address *url.URL, lookupFunc internal_net.IPLookupFunc) error { + return IsHostnameBlocked(denyList, address.Hostname(), lookupFunc) +} diff --git a/internal/denylist/host_checker_test.go b/internal/denylist/host_checker_test.go index e6ed5ba69..969354f86 100644 --- a/internal/denylist/host_checker_test.go +++ b/internal/denylist/host_checker_test.go @@ -7,23 +7,25 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/zitadel/zitadel/internal/zerrors" ) -func TestIsHostBlocked(t *testing.T) { +func TestIsURLBlocked(t *testing.T) { t.Parallel() - var denyList = []AddressChecker{ - mustNewHostChecker(t, "192.168.5.0/24"), - mustNewHostChecker(t, "127.0.0.1"), - mustNewHostChecker(t, "test.com"), + + denyList := []AddressChecker{ + NewHostChecker("192.168.5.0/24"), + NewHostChecker("127.0.0.1"), + NewHostChecker("test.com"), } + type fields struct { lookup func(host string) ([]net.IP, error) } type args struct { address *url.URL } + tests := []struct { name string fields fields @@ -31,21 +33,31 @@ func TestIsHostBlocked(t *testing.T) { want error }{ { - name: "in range", + name: "in range (CIDR match)", + fields: fields{ + lookup: func(host string) ([]net.IP, error) { + return []net.IP{net.ParseIP("192.168.5.4")}, nil + }, + }, args: args{ address: mustNewURL(t, "https://192.168.5.4/hodor"), }, want: NewAddressDeniedError("192.168.5.0/24"), }, { - name: "exact ip", + name: "exact ip literal match", + fields: fields{ + lookup: func(host string) ([]net.IP, error) { + return []net.IP{net.ParseIP("127.0.0.1")}, nil + }, + }, args: args{ address: mustNewURL(t, "http://127.0.0.1:8080/hodor"), }, want: NewAddressDeniedError("127.0.0.1"), }, { - name: "address match", + name: "domain string match", fields: fields{ lookup: func(host string) ([]net.IP, error) { return []net.IP{net.ParseIP("194.264.52.4")}, nil @@ -57,7 +69,7 @@ func TestIsHostBlocked(t *testing.T) { want: NewAddressDeniedError("test.com"), }, { - name: "address not match", + name: "allowed domain string pass", fields: fields{ lookup: func(host string) ([]net.IP, error) { return []net.IP{net.ParseIP("194.264.52.4")}, nil @@ -69,7 +81,7 @@ func TestIsHostBlocked(t *testing.T) { want: nil, }, { - name: "looked up ip matches", + name: "unmatched domain resolves to blocked IP (SSRF pivot attempt)", fields: fields{ lookup: func(host string) ([]net.IP, error) { return []net.IP{net.ParseIP("127.0.0.1")}, nil @@ -81,7 +93,7 @@ func TestIsHostBlocked(t *testing.T) { want: NewAddressDeniedError("127.0.0.1"), }, { - name: "lookup failure", + name: "dns resolver failures are isolated", fields: fields{ lookup: func(host string) ([]net.IP, error) { return nil, io.EOF @@ -93,23 +105,48 @@ func TestIsHostBlocked(t *testing.T) { want: zerrors.ThrowInternal(io.EOF, "NET-4m9s2", "lookup failed"), }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := IsHostBlocked(denyList, tt.args.address, tt.fields.lookup) + got := IsURLBlocked(denyList, tt.args.address, tt.fields.lookup) assert.ErrorIs(t, got, tt.want) }) } } -func mustNewHostChecker(t *testing.T, ip string) AddressChecker { - t.Helper() - checker, err := NewHostChecker(ip) - if err != nil { - t.Errorf("unable to parse cidr of %q because: %v", ip, err) - t.FailNow() +func TestIsHostnameBlocked_EdgeCases(t *testing.T) { + t.Parallel() + + denyList := []AddressChecker{ + NewHostChecker("127.0.0.1"), + NewHostChecker("blocked.example.com"), } - return checker + + t.Run("blocked by host lookup via hostname method", func(t *testing.T) { + t.Parallel() + err := IsHostnameBlocked(denyList, "safe.example.com", func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("127.0.0.1")}, nil + }) + assert.ErrorIs(t, err, NewAddressDeniedError("127.0.0.1")) + }) + + t.Run("blocked by exact domain string mapping", func(t *testing.T) { + t.Parallel() + err := IsHostnameBlocked(denyList, "blocked.example.com", func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("192.0.2.1")}, nil + }) + assert.ErrorIs(t, err, NewAddressDeniedError("blocked.example.com")) + }) + + t.Run("ipv6 target check compatibility", func(t *testing.T) { + t.Parallel() + // URL parsing or raw string handling yields plain IPv6 targets, make sure checker behaves cleanly + err := IsHostnameBlocked(denyList, "::1", func(_ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("::1")}, nil + }) + assert.NoError(t, err) + }) } func mustNewURL(t *testing.T, raw string) *url.URL { diff --git a/internal/denylist/http_transport.go b/internal/denylist/http_transport.go new file mode 100644 index 000000000..152440016 --- /dev/null +++ b/internal/denylist/http_transport.go @@ -0,0 +1,37 @@ +package denylist + +import ( + "net" + "net/http" + "syscall" + "time" +) + +// NewHTTPTransport returns a cloned default transport that enforces denylist checks +// right before each TCP dial to avoid DNS rebinding TOCTOU gaps. +func NewHTTPTransport(denyList []AddressChecker) http.RoundTripper { + base := http.DefaultTransport.(*http.Transport).Clone() + if len(denyList) == 0 { + return base + } + dialer := &net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + Control: func(network, address string, c syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + + parsedIP := net.ParseIP(host) + if parsedIP == nil { // at this point, it must be an IP so it should never happen + return &net.DNSError{Err: "invalid IP address", Name: host} + } + + return IsHostBlocked(denyList, host, parsedIP) + }, + } + base.DialContext = dialer.DialContext + + return base +} diff --git a/internal/denylist/http_transport_test.go b/internal/denylist/http_transport_test.go new file mode 100644 index 000000000..06103671f --- /dev/null +++ b/internal/denylist/http_transport_test.go @@ -0,0 +1,111 @@ +package denylist + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewHTTPTransport_Allowed(t *testing.T) { + t.Parallel() + + // Create a denylist that does NOT block our local test server + denyList := []AddressChecker{ + NewHostChecker("192.168.1.0/24"), + NewHostChecker("10.0.0.1"), + } + + // Spin up a dummy local test server. It will listen on a loopback address like 127.0.0.1 or ::1 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("success")) + })) + defer server.Close() + + // Instantiate the transport with the denylist rules + transport := NewHTTPTransport(denyList) + client := &http.Client{Transport: transport} + + // Execute the request to the allowed local server target + resp, err := client.Get(server.URL) + assert.NoError(t, err) + if resp != nil { + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + } +} + +func TestNewHTTPTransport_BlockedByControl(t *testing.T) { + t.Parallel() + + // Spin up a test server so we can dynamically extract its specific local listening loopback IP + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Parse out the target host/ip text from the server URL + u, err := net.Dial("tcp", server.Listener.Addr().String()) + assert.NoError(t, err) + localIP, _, err := net.SplitHostPort(u.RemoteAddr().String()) + assert.NoError(t, err) + _ = u.Close() + + // Add the explicitly discovered test server IP to the denylist + denyList := []AddressChecker{ + NewHostChecker(localIP), + } + + transport := NewHTTPTransport(denyList) + client := &http.Client{Transport: transport} + + // This must fail because the Control hook interceptor throws an address denied error + // right before the low-level TCP handshake begins. + _, err = client.Get(server.URL) //nolint:bodyclose + assert.Error(t, err) + + // Because Go wraps dialer context execution errors inside a generic url.Error, + // we verify that the true root cause matches our address denial definition. + var addressDeniedErr *AddressDeniedError + assert.ErrorAs(t, err, &addressDeniedErr) +} + +func TestNewHTTPTransport_EmptyDenylistReturnsDefaultClone(t *testing.T) { + t.Parallel() + + // An empty list should bypass adding a dialer wrapper entirely for performance + transport := NewHTTPTransport([]AddressChecker{}) + + assert.NotNil(t, transport) + httpTransport, ok := transport.(*http.Transport) + assert.True(t, ok) + + // Ensure that DialContext is still populated (inherited cleanly from http.DefaultTransport Clone) + assert.NotNil(t, httpTransport.DialContext) +} + +func TestNewHTTPTransport_MalformedAddressHandling(t *testing.T) { + t.Parallel() + + denyList := []AddressChecker{ + NewHostChecker("127.0.0.1"), + } + + transport := NewHTTPTransport(denyList) + httpTransport, ok := transport.(*http.Transport) + assert.True(t, ok) + + // Explicitly invoke DialContext with an unparseable address format (missing a valid port boundary) + // to test how the underlying Control code deals with structural string parsing failure exceptions. + _, err := httpTransport.DialContext(context.Background(), "tcp", "malformed-address-without-port") + assert.Error(t, err) + + // Ensure it didn't crash and returns a clean, standard connection framework error context + var netErr net.Error + assert.True(t, errors.As(err, &netErr)) +} diff --git a/internal/execution/execution.go b/internal/execution/execution.go index 58e79064c..692a3e7a1 100644 --- a/internal/execution/execution.go +++ b/internal/execution/execution.go @@ -9,9 +9,7 @@ import ( "encoding/json" "encoding/pem" "io" - "net" "net/http" - "net/url" "sync" "time" @@ -22,7 +20,6 @@ import ( zhttp "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/oidc/sign" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/denylist" "github.com/zitadel/zitadel/internal/domain" target_domain "github.com/zitadel/zitadel/internal/execution/target" "github.com/zitadel/zitadel/internal/repository/execution" @@ -46,7 +43,7 @@ func CallTargets( info ContextInfo, alg crypto.EncryptionAlgorithm, activeSigningKey GetActiveSigningWebKey, - deniedIPList []denylist.AddressChecker, + client *http.Client, ) (_ any, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -58,7 +55,7 @@ func CallTargets( for _, target := range targets { // call the type of target - resp, err := CallTarget(ctx, target, info, alg, signerOnce, encrypters, deniedIPList) + resp, err := CallTarget(ctx, target, info, alg, signerOnce, encrypters, client) // handle error if interrupt is set logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "target", target.GetTargetID()).OnError(err).Error("error calling target") if err != nil && target.IsInterruptOnError() { @@ -86,7 +83,7 @@ func CallTarget( alg crypto.EncryptionAlgorithm, signerOnce sign.SignerFunc, encrypters *sync.Map, - deniedIPList []denylist.AddressChecker, + client *http.Client, ) (res []byte, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -96,16 +93,6 @@ func CallTarget( return nil, zerrors.ThrowInternal(err, "EXEC-thiiCh5b", "Errors.Internal") } - if target.GetEndpoint() != "" { - endpointURL, err := url.Parse(target.GetEndpoint()) - if err != nil { - return nil, zerrors.ThrowInvalidArgument(err, "EXEC-N5lu09", "Errors.Endpoint.Invalid") - } - if err := denylist.IsHostBlocked(deniedIPList, endpointURL, net.LookupIP); err != nil { - return nil, zerrors.ThrowInvalidArgument(err, "EXEC-N5lu09", "Errors.Endpoint.Denied") - } - } - body, err := payload(ctx, info.GetHTTPRequestBody(), target, signerOnce, encrypters) if err != nil { return nil, err @@ -114,13 +101,13 @@ func CallTarget( switch target.GetTargetType() { // get request, ignore response and return request and error for handling in list of targets case target_domain.TargetTypeWebhook: - return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), body, signingKey) + return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), body, signingKey, client) // get request, return response and error case target_domain.TargetTypeCall: - return Call(ctx, target.GetEndpoint(), target.GetTimeout(), body, signingKey) + return Call(ctx, target.GetEndpoint(), target.GetTimeout(), body, signingKey, client) case target_domain.TargetTypeAsync: go func(ctx context.Context, target target_domain.Target, info []byte) { - if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info, signingKey); err != nil { + if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info, signingKey, client); err != nil { logging.WithFields("target", target.GetTargetID()).OnError(err).Info(err) } }(context.WithoutCancel(ctx), target, body) @@ -238,13 +225,13 @@ func publicKeyFromBytes(data []byte) (any, jose.KeyAlgorithm, error) { } // webhook call a webhook, ignore the response but return the errror -func webhook(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) error { - _, err := Call(ctx, url, timeout, body, signingKey) +func webhook(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string, client *http.Client) error { + _, err := Call(ctx, url, timeout, body, signingKey, client) return err } // Call function to do a post HTTP request to a desired url with timeout -func Call(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) (_ []byte, err error) { +func Call(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string, client *http.Client) (_ []byte, err error) { ctx, cancel := context.WithTimeout(ctx, timeout) ctx, span := tracing.NewSpan(ctx) defer func() { @@ -261,7 +248,6 @@ func Call(ctx context.Context, url string, timeout time.Duration, body []byte, s req.Header.Set(actions.SigningHeader, actions.ComputeSignatureHeader(time.Now(), body, signingKey)) } - client := http.DefaultClient resp, err := client.Do(req) if err != nil { return nil, err diff --git a/internal/execution/execution_test.go b/internal/execution/execution_test.go index f41e7a9d5..f31660401 100644 --- a/internal/execution/execution_test.go +++ b/internal/execution/execution_test.go @@ -423,9 +423,7 @@ func Test_CallTarget(t *testing.T) { } func Test_CallTargets(t *testing.T) { - deniedLocalhost, err := denylist.NewHostChecker("127.0.0.1") - require.NoError(t, err) - deniedIPs := []denylist.AddressChecker{deniedLocalhost} + deniedIPs := []denylist.AddressChecker{denylist.NewHostChecker("127.0.0.1")} type args struct { ctx context.Context info *middleware.ContextInfoRequest @@ -720,7 +718,7 @@ func checkRequest(t *testing.T, sent *http.Request, method string, checkExpected func testCall(ctx context.Context, timeout time.Duration, body []byte, signingKey string) func(string) ([]byte, error) { return func(url string) ([]byte, error) { - return execution.Call(ctx, url, timeout, body, signingKey) + return execution.Call(ctx, url, timeout, body, signingKey, http.DefaultClient) } } @@ -734,7 +732,7 @@ func testCallTarget(ctx context.Context, ) func(string) ([]byte, error) { return func(url string) (r []byte, err error) { target.Endpoint = url - return execution.CallTarget(ctx, target, info, alg, signerOnce, encrypters, actionsDenyList) + return execution.CallTarget(ctx, target, info, alg, signerOnce, encrypters, &http.Client{Transport: denylist.NewHTTPTransport(actionsDenyList)}) } } @@ -751,7 +749,7 @@ func testCallTargets(ctx context.Context, t.Endpoint = urls[i] targets[i] = t } - return execution.CallTargets(ctx, targets, info, alg, activeSigningKey, actionsDenyList) + return execution.CallTargets(ctx, targets, info, alg, activeSigningKey, &http.Client{Transport: denylist.NewHTTPTransport(actionsDenyList)}) } } diff --git a/internal/execution/projections.go b/internal/execution/projections.go index de755a7e7..ce2395475 100644 --- a/internal/execution/projections.go +++ b/internal/execution/projections.go @@ -2,6 +2,7 @@ package execution import ( "context" + "net/http" "time" "github.com/zitadel/zitadel/internal/crypto" @@ -16,12 +17,13 @@ var ( func Register( ctx context.Context, workerConfig WorkerConfig, + httpClient *http.Client, queue *queue.Queue, targetEncAlg crypto.EncryptionAlgorithm, activeSigningKey GetActiveSigningWebKey, ) { queue.ShouldStart() - queue.AddWorkers(ctx, NewWorker(workerConfig, targetEncAlg, activeSigningKey, time.Now)) + queue.AddWorkers(ctx, NewWorker(workerConfig, targetEncAlg, activeSigningKey, time.Now, httpClient)) } func Start(ctx context.Context) { diff --git a/internal/execution/worker.go b/internal/execution/worker.go index a1b3d46d1..2b52419dd 100644 --- a/internal/execution/worker.go +++ b/internal/execution/worker.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "time" "github.com/riverqueue/river" @@ -18,8 +19,9 @@ import ( type Worker struct { river.WorkerDefaults[*exec_repo.Request] - config WorkerConfig - now NowFunc + config WorkerConfig + httpClient *http.Client + now NowFunc targetEncAlg crypto.EncryptionAlgorithm activeSigningKey GetActiveSigningWebKey @@ -47,7 +49,7 @@ func (w *Worker) Work(ctx context.Context, job *river.Job[*exec_repo.Request]) e return river.JobCancel(fmt.Errorf("unable to unmarshal targets because %w", err)) } - _, err = CallTargets(ctx, targets, exec_repo.ContextInfoFromRequest(job.Args), w.targetEncAlg, w.activeSigningKey, w.config.DenyList) + _, err = CallTargets(ctx, targets, exec_repo.ContextInfoFromRequest(job.Args), w.targetEncAlg, w.activeSigningKey, w.httpClient) if err != nil { // If there is an error returned from the targets, it means that the execution was interrupted return river.JobCancel(fmt.Errorf("interruption during call of targets because %w", err)) @@ -70,9 +72,11 @@ func NewWorker( targetEncAlg crypto.EncryptionAlgorithm, activeSigningKey GetActiveSigningWebKey, now NowFunc, + httpClient *http.Client, ) *Worker { return &Worker{ config: config, + httpClient: httpClient, now: now, targetEncAlg: targetEncAlg, activeSigningKey: activeSigningKey, diff --git a/internal/execution/worker_test.go b/internal/execution/worker_test.go index 2883c1aea..e895ab347 100644 --- a/internal/execution/worker_test.go +++ b/internal/execution/worker_test.go @@ -61,6 +61,7 @@ func newExecutionWorker(f fieldsWorker) *execution.Worker { nil, mockGetActiveSigningWebKey, f.now, + http.DefaultClient, ) } diff --git a/internal/idp/providers/apple/apple.go b/internal/idp/providers/apple/apple.go index 3a702b94b..35c607c6e 100644 --- a/internal/idp/providers/apple/apple.go +++ b/internal/idp/providers/apple/apple.go @@ -2,6 +2,7 @@ package apple import ( "crypto/ecdsa" + "net/http" "time" "github.com/go-jose/go-jose/v4" @@ -25,13 +26,13 @@ type Provider struct { *oidc.Provider } -func New(clientID, teamID, keyID, callbackURL string, key []byte, scopes []string, options ...oidc.ProviderOpts) (*Provider, error) { +func New(clientID, teamID, keyID, callbackURL string, key []byte, scopes []string, httpClient *http.Client, options ...oidc.ProviderOpts) (*Provider, error) { secret, err := clientSecretFromPrivateKey(key, teamID, clientID, keyID) if err != nil { return nil, err } options = append(options, oidc.WithResponseMode("form_post")) - rp, err := oidc.New(name, issuer, clientID, secret, callbackURL, scopes, oidc.DefaultMapper, options...) + rp, err := oidc.New(name, issuer, clientID, secret, callbackURL, scopes, oidc.DefaultMapper, httpClient, options...) if err != nil { return nil, err } diff --git a/internal/idp/providers/apple/apple_test.go b/internal/idp/providers/apple/apple_test.go index 7d1f3a848..711e7ce89 100644 --- a/internal/idp/providers/apple/apple_test.go +++ b/internal/idp/providers/apple/apple_test.go @@ -2,6 +2,7 @@ package apple import ( "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -57,7 +58,7 @@ func TestProvider_BeginAuth(t *testing.T) { a := assert.New(t) r := require.New(t) - provider, err := New(tt.fields.clientID, tt.fields.teamID, tt.fields.keyID, tt.fields.redirectURI, tt.fields.privateKey, tt.fields.scopes) + provider, err := New(tt.fields.clientID, tt.fields.teamID, tt.fields.keyID, tt.fields.redirectURI, tt.fields.privateKey, tt.fields.scopes, http.DefaultClient) r.NoError(err) ctx := context.Background() session, err := provider.BeginAuth(ctx, "testState") diff --git a/internal/idp/providers/apple/session_test.go b/internal/idp/providers/apple/session_test.go index 3c1ab5976..1e306de05 100644 --- a/internal/idp/providers/apple/session_test.go +++ b/internal/idp/providers/apple/session_test.go @@ -3,6 +3,7 @@ package apple import ( "context" "errors" + "net/http" "testing" "time" @@ -156,7 +157,7 @@ func TestSession_FetchUser(t *testing.T) { // call the real discovery endpoint gock.New(issuer).Get(openid.DiscoveryEndpoint).EnableNetworking() - provider, err := New(tt.fields.clientID, tt.fields.teamID, tt.fields.keyID, tt.fields.redirectURI, tt.fields.privateKey, tt.fields.scopes) + provider, err := New(tt.fields.clientID, tt.fields.teamID, tt.fields.keyID, tt.fields.redirectURI, tt.fields.privateKey, tt.fields.scopes, http.DefaultClient) require.NoError(t, err) session := &Session{ diff --git a/internal/idp/providers/azuread/azuread.go b/internal/idp/providers/azuread/azuread.go index 3e2594b7c..dd690e7d4 100644 --- a/internal/idp/providers/azuread/azuread.go +++ b/internal/idp/providers/azuread/azuread.go @@ -2,6 +2,7 @@ package azuread import ( "fmt" + "net/http" "github.com/zitadel/oidc/v3/pkg/oidc" "golang.org/x/oauth2" @@ -88,7 +89,7 @@ func WithOAuthOptions(opts ...oauth.ProviderOpts) ProviderOptions { // New creates an AzureAD provider using the [oauth.Provider] (OAuth 2.0 generic provider). // By default, it uses the [CommonTenant] and unverified emails. -func New(name, clientID, clientSecret, redirectURI string, scopes []string, opts ...ProviderOptions) (*Provider, error) { +func New(name, clientID, clientSecret, redirectURI string, scopes []string, httpClient *http.Client, opts ...ProviderOptions) (*Provider, error) { provider := &Provider{ tenant: CommonTenant, options: make([]oauth.ProviderOpts, 0), @@ -104,6 +105,7 @@ func New(name, clientID, clientSecret, redirectURI string, scopes []string, opts func() idp.User { return &User{isEmailVerified: provider.emailVerified} }, + httpClient, provider.options..., ) if err != nil { diff --git a/internal/idp/providers/azuread/azuread_test.go b/internal/idp/providers/azuread/azuread_test.go index e46815cc8..9d367e00b 100644 --- a/internal/idp/providers/azuread/azuread_test.go +++ b/internal/idp/providers/azuread/azuread_test.go @@ -2,6 +2,7 @@ package azuread import ( "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -74,7 +75,7 @@ func TestProvider_BeginAuth(t *testing.T) { a := assert.New(t) r := require.New(t) - provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) + provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient, tt.fields.options...) r.NoError(err) ctx := context.Background() @@ -169,7 +170,7 @@ func TestProvider_Options(t *testing.T) { t.Run(tt.name, func(t *testing.T) { a := assert.New(t) - provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) + provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient, tt.fields.options...) require.NoError(t, err) a.Equal(tt.want.name, provider.Name()) diff --git a/internal/idp/providers/azuread/session_test.go b/internal/idp/providers/azuread/session_test.go index 8215184ae..42d947427 100644 --- a/internal/idp/providers/azuread/session_test.go +++ b/internal/idp/providers/azuread/session_test.go @@ -244,7 +244,7 @@ func TestSession_FetchUser(t *testing.T) { tt.fields.httpMock() a := assert.New(t) - provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) + provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient, tt.fields.options...) require.NoError(t, err) session := &Session{ @@ -395,7 +395,7 @@ func TestSession_RetrievePreviousID(t *testing.T) { tt.fields.httpMock() a := assert.New(t) - provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes) + provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient) require.NoError(t, err) session := &Session{ Provider: provider, diff --git a/internal/idp/providers/github/github.go b/internal/idp/providers/github/github.go index 4559e576f..bc3195687 100644 --- a/internal/idp/providers/github/github.go +++ b/internal/idp/providers/github/github.go @@ -1,6 +1,7 @@ package github import ( + "net/http" "strconv" "time" @@ -23,13 +24,13 @@ const ( var _ idp.Provider = (*Provider)(nil) // New creates a GitHub.com provider using the [oauth.Provider] (OAuth 2.0 generic provider) -func New(clientID, secret, callbackURL string, scopes []string, options ...oauth.ProviderOpts) (*Provider, error) { - return NewCustomURL(name, clientID, secret, callbackURL, authURL, tokenURL, profileURL, scopes, options...) +func New(clientID, secret, callbackURL string, scopes []string, httpClient *http.Client, options ...oauth.ProviderOpts) (*Provider, error) { + return NewCustomURL(name, clientID, secret, callbackURL, authURL, tokenURL, profileURL, scopes, httpClient, options...) } // NewCustomURL creates a GitHub provider using the [oauth.Provider] (OAuth 2.0 generic provider) // with custom endpoints, e.g. GitHub Enterprise server -func NewCustomURL(name, clientID, secret, callbackURL, authURL, tokenURL, profileURL string, scopes []string, options ...oauth.ProviderOpts) (*Provider, error) { +func NewCustomURL(name, clientID, secret, callbackURL, authURL, tokenURL, profileURL string, scopes []string, httpClient *http.Client, options ...oauth.ProviderOpts) (*Provider, error) { rp, err := oauth.New( newConfig(clientID, secret, callbackURL, authURL, tokenURL, scopes), name, @@ -37,6 +38,7 @@ func NewCustomURL(name, clientID, secret, callbackURL, authURL, tokenURL, profil func() idp.User { return new(User) }, + httpClient, options..., ) if err != nil { diff --git a/internal/idp/providers/github/github_test.go b/internal/idp/providers/github/github_test.go index 42f03c050..5eb3f31f4 100644 --- a/internal/idp/providers/github/github_test.go +++ b/internal/idp/providers/github/github_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -41,7 +42,7 @@ func TestProvider_BeginAuth(t *testing.T) { a := assert.New(t) r := require.New(t) - provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient, tt.fields.options...) r.NoError(err) ctx := context.Background() diff --git a/internal/idp/providers/github/session_test.go b/internal/idp/providers/github/session_test.go index da4d40a6f..d193a1a6a 100644 --- a/internal/idp/providers/github/session_test.go +++ b/internal/idp/providers/github/session_test.go @@ -395,7 +395,7 @@ func TestSession_FetchUser(t *testing.T) { } a := assert.New(t) - provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient, tt.fields.options...) require.NoError(t, err) session := &Session{ diff --git a/internal/idp/providers/gitlab/gitlab.go b/internal/idp/providers/gitlab/gitlab.go index 76e9a74b4..1d69ff5d6 100644 --- a/internal/idp/providers/gitlab/gitlab.go +++ b/internal/idp/providers/gitlab/gitlab.go @@ -1,6 +1,8 @@ package gitlab import ( + "net/http" + openid "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/zitadel/internal/idp" @@ -20,13 +22,13 @@ type Provider struct { } // New creates a GitLab.com provider using the [oidc.Provider] (OIDC generic provider) -func New(clientID, clientSecret, redirectURI string, scopes []string, options ...oidc.ProviderOpts) (*Provider, error) { - return NewCustomIssuer(name, issuer, clientID, clientSecret, redirectURI, scopes, options...) +func New(clientID, clientSecret, redirectURI string, scopes []string, httpClient *http.Client, options ...oidc.ProviderOpts) (*Provider, error) { + return NewCustomIssuer(name, issuer, clientID, clientSecret, redirectURI, scopes, httpClient, options...) } // NewCustomIssuer creates a GitLab provider using the [oidc.Provider] (OIDC generic provider) // with a custom issuer for self-managed instances -func NewCustomIssuer(name, issuer, clientID, clientSecret, redirectURI string, scopes []string, options ...oidc.ProviderOpts) (*Provider, error) { +func NewCustomIssuer(name, issuer, clientID, clientSecret, redirectURI string, scopes []string, httpClient *http.Client, options ...oidc.ProviderOpts) (*Provider, error) { if len(scopes) == 0 { // the OIDC provider would set `openid profile email phone` as default scope, // but since gitlab does not handle unknown scopes correctly (phone) and returns an error, @@ -35,7 +37,7 @@ func NewCustomIssuer(name, issuer, clientID, clientSecret, redirectURI string, s } // gitlab is currently not able to handle the prompt `select_account`: // https://gitlab.com/gitlab-org/gitlab/-/issues/377368 - rp, err := oidc.New(name, issuer, clientID, clientSecret, redirectURI, scopes, oidc.DefaultMapper, options...) + rp, err := oidc.New(name, issuer, clientID, clientSecret, redirectURI, scopes, oidc.DefaultMapper, httpClient, options...) if err != nil { return nil, err } diff --git a/internal/idp/providers/gitlab/gitlab_test.go b/internal/idp/providers/gitlab/gitlab_test.go index 99b28c500..55befd248 100644 --- a/internal/idp/providers/gitlab/gitlab_test.go +++ b/internal/idp/providers/gitlab/gitlab_test.go @@ -2,6 +2,7 @@ package gitlab import ( "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -53,7 +54,7 @@ func TestProvider_BeginAuth(t *testing.T) { a := assert.New(t) r := require.New(t) - provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.opts...) + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient, tt.fields.opts...) r.NoError(err) ctx := context.Background() session, err := provider.BeginAuth(ctx, "testState") diff --git a/internal/idp/providers/gitlab/session_test.go b/internal/idp/providers/gitlab/session_test.go index 0853f3ce3..f9e8422df 100644 --- a/internal/idp/providers/gitlab/session_test.go +++ b/internal/idp/providers/gitlab/session_test.go @@ -3,6 +3,7 @@ package gitlab import ( "context" "errors" + "net/http" "testing" "time" @@ -166,7 +167,7 @@ func TestProvider_FetchUser(t *testing.T) { // call the real discovery endpoint gock.New(issuer).Get(openid.DiscoveryEndpoint).EnableNetworking() - provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient, tt.fields.options...) require.NoError(t, err) session := &oidc.Session{ diff --git a/internal/idp/providers/google/google.go b/internal/idp/providers/google/google.go index 083d4aef6..47eec8a9a 100644 --- a/internal/idp/providers/google/google.go +++ b/internal/idp/providers/google/google.go @@ -1,6 +1,8 @@ package google import ( + "net/http" + openid "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/zitadel/internal/idp" @@ -20,8 +22,8 @@ type Provider struct { } // New creates a Google provider using the [oidc.Provider] (OIDC generic provider) -func New(clientID, clientSecret, redirectURI string, scopes []string, opts ...oidc.ProviderOpts) (*Provider, error) { - rp, err := oidc.New(name, issuer, clientID, clientSecret, redirectURI, scopes, userMapper, append(opts, oidc.WithSelectAccount())...) +func New(clientID, clientSecret, redirectURI string, scopes []string, httpClient *http.Client, opts ...oidc.ProviderOpts) (*Provider, error) { + rp, err := oidc.New(name, issuer, clientID, clientSecret, redirectURI, scopes, userMapper, httpClient, append(opts, oidc.WithSelectAccount())...) if err != nil { return nil, err } diff --git a/internal/idp/providers/google/google_test.go b/internal/idp/providers/google/google_test.go index b8f31b86e..4da104010 100644 --- a/internal/idp/providers/google/google_test.go +++ b/internal/idp/providers/google/google_test.go @@ -2,6 +2,7 @@ package google import ( "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -41,7 +42,7 @@ func TestProvider_BeginAuth(t *testing.T) { a := assert.New(t) r := require.New(t) - provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes) + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient) r.NoError(err) ctx := context.Background() diff --git a/internal/idp/providers/google/session_test.go b/internal/idp/providers/google/session_test.go index 1915adf1b..178ae8373 100644 --- a/internal/idp/providers/google/session_test.go +++ b/internal/idp/providers/google/session_test.go @@ -3,6 +3,7 @@ package google import ( "context" "errors" + "net/http" "testing" "time" @@ -167,7 +168,7 @@ func TestSession_FetchUser(t *testing.T) { // call the real discovery endpoint gock.New(issuer).Get(openid.DiscoveryEndpoint).EnableNetworking() - provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes) + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, http.DefaultClient) require.NoError(t, err) session := &oidc.Session{ diff --git a/internal/idp/providers/jwt/jwt.go b/internal/idp/providers/jwt/jwt.go index a58be0aa3..cb1d7673c 100644 --- a/internal/idp/providers/jwt/jwt.go +++ b/internal/idp/providers/jwt/jwt.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "errors" + "net/http" "net/url" "github.com/zitadel/zitadel/internal/crypto" @@ -34,6 +35,7 @@ type Provider struct { isAutoCreation bool isAutoUpdate bool encryptionAlg crypto.EncryptionAlgorithm + httpClient *http.Client } type ProviderOpts func(provider *Provider) @@ -68,7 +70,7 @@ func WithAutoUpdate() ProviderOpts { } // New creates a JWT provider -func New(name, issuer, jwtEndpoint, keysEndpoint, headerName, audience string, encryptionAlg crypto.EncryptionAlgorithm, options ...ProviderOpts) (*Provider, error) { +func New(name, issuer, jwtEndpoint, keysEndpoint, headerName, audience string, encryptionAlg crypto.EncryptionAlgorithm, httpClient *http.Client, options ...ProviderOpts) (*Provider, error) { provider := &Provider{ name: name, issuer: issuer, @@ -77,6 +79,7 @@ func New(name, issuer, jwtEndpoint, keysEndpoint, headerName, audience string, e headerName: headerName, audience: audience, encryptionAlg: encryptionAlg, + httpClient: httpClient, } for _, option := range options { option(provider) diff --git a/internal/idp/providers/jwt/jwt_test.go b/internal/idp/providers/jwt/jwt_test.go index ccbfdaa3f..6ba5a624f 100644 --- a/internal/idp/providers/jwt/jwt_test.go +++ b/internal/idp/providers/jwt/jwt_test.go @@ -3,6 +3,7 @@ package jwt import ( "context" "errors" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -114,6 +115,7 @@ func TestProvider_BeginAuth(t *testing.T) { tt.fields.headerName, tt.fields.audience, tt.fields.encryptionAlg(t), + http.DefaultClient, ) require.NoError(t, err) @@ -221,6 +223,7 @@ func TestProvider_Options(t *testing.T) { tt.fields.headerName, tt.fields.audience, tt.fields.encryptionAlg(t), + http.DefaultClient, tt.fields.opts..., ) require.NoError(t, err) diff --git a/internal/idp/providers/jwt/session.go b/internal/idp/providers/jwt/session.go index 820cf0645..c3e4e96bc 100644 --- a/internal/idp/providers/jwt/session.go +++ b/internal/idp/providers/jwt/session.go @@ -87,7 +87,7 @@ func (s *Session) validateToken(ctx context.Context, token string) (*oidc.IDToke } logging.Debug("begin signature validation") - keySet := rp.NewRemoteKeySet(http.DefaultClient, s.Provider.keysEndpoint) + keySet := rp.NewRemoteKeySet(s.httpClient, s.keysEndpoint) if err = oidc.CheckSignature(ctx, token, payload, claims, nil, keySet); err != nil { return nil, fmt.Errorf("%w: invalid signature: %v", ErrInvalidToken, err) } diff --git a/internal/idp/providers/jwt/session_test.go b/internal/idp/providers/jwt/session_test.go index daf82befe..6d9232e24 100644 --- a/internal/idp/providers/jwt/session_test.go +++ b/internal/idp/providers/jwt/session_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net/http" "testing" "time" @@ -378,6 +379,7 @@ func TestSession_FetchUser(t *testing.T) { tt.fields.headerName, tt.fields.audience, tt.fields.encryptionAlg(t), + http.DefaultClient, ) require.NoError(t, err) diff --git a/internal/idp/providers/oauth/oauth2.go b/internal/idp/providers/oauth/oauth2.go index a790c550f..551f04046 100644 --- a/internal/idp/providers/oauth/oauth2.go +++ b/internal/idp/providers/oauth/oauth2.go @@ -2,6 +2,7 @@ package oauth import ( "context" + "net/http" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" @@ -65,12 +66,13 @@ func WithRelyingPartyOption(option rp.Option) ProviderOpts { } // New creates a generic OAuth 2.0 provider -func New(config *oauth2.Config, name, userEndpoint string, user func() idp.User, options ...ProviderOpts) (provider *Provider, err error) { +func New(config *oauth2.Config, name, userEndpoint string, user func() idp.User, httpClient *http.Client, options ...ProviderOpts) (provider *Provider, err error) { provider = &Provider{ name: name, userEndpoint: userEndpoint, user: user, generateVerifier: oauth2.GenerateVerifier, + options: []rp.Option{rp.WithHTTPClient(httpClient)}, } for _, option := range options { option(provider) diff --git a/internal/idp/providers/oauth/oauth2_test.go b/internal/idp/providers/oauth/oauth2_test.go index 93a0dd404..9b0fc0011 100644 --- a/internal/idp/providers/oauth/oauth2_test.go +++ b/internal/idp/providers/oauth/oauth2_test.go @@ -2,6 +2,7 @@ package oauth import ( "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -70,7 +71,7 @@ func TestProvider_BeginAuth(t *testing.T) { a := assert.New(t) r := require.New(t) - provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper, tt.fields.options...) + provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper, http.DefaultClient, tt.fields.options...) r.NoError(err) provider.generateVerifier = func() string { return "pkceOAuthVerifier" @@ -170,7 +171,7 @@ func TestProvider_Options(t *testing.T) { t.Run(tt.name, func(t *testing.T) { a := assert.New(t) - provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper, tt.fields.options...) + provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper, http.DefaultClient, tt.fields.options...) require.NoError(t, err) a.Equal(tt.want.name, provider.Name()) diff --git a/internal/idp/providers/oauth/session_test.go b/internal/idp/providers/oauth/session_test.go index ce6fa3f10..bb62b17f8 100644 --- a/internal/idp/providers/oauth/session_test.go +++ b/internal/idp/providers/oauth/session_test.go @@ -238,7 +238,7 @@ func TestProvider_FetchUser(t *testing.T) { tt.fields.httpMock("https://oauth2.com") a := assert.New(t) - provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper) + provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper, http.DefaultClient) require.NoError(t, err) session := &Session{ diff --git a/internal/idp/providers/oidc/oidc.go b/internal/idp/providers/oidc/oidc.go index cd3ac764f..a2cf29d39 100644 --- a/internal/idp/providers/oidc/oidc.go +++ b/internal/idp/providers/oidc/oidc.go @@ -2,6 +2,7 @@ package oidc import ( "context" + "net/http" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" @@ -101,11 +102,12 @@ var DefaultMapper UserInfoMapper = func(info *oidc.UserInfo) idp.User { } // New creates a generic OIDC provider -func New(name, issuer, clientID, clientSecret, redirectURI string, scopes []string, userInfoMapper UserInfoMapper, options ...ProviderOpts) (provider *Provider, err error) { +func New(name, issuer, clientID, clientSecret, redirectURI string, scopes []string, userInfoMapper UserInfoMapper, client *http.Client, options ...ProviderOpts) (provider *Provider, err error) { provider = &Provider{ name: name, userInfoMapper: userInfoMapper, generateVerifier: oauth2.GenerateVerifier, + options: []rp.Option{rp.WithHTTPClient(client)}, } for _, option := range options { option(provider) diff --git a/internal/idp/providers/oidc/oidc_test.go b/internal/idp/providers/oidc/oidc_test.go index 86e23f95d..d6dcd6d0d 100644 --- a/internal/idp/providers/oidc/oidc_test.go +++ b/internal/idp/providers/oidc/oidc_test.go @@ -2,6 +2,7 @@ package oidc import ( "context" + "net/http" "testing" "github.com/h2non/gock" @@ -88,7 +89,7 @@ func TestProvider_BeginAuth(t *testing.T) { a := assert.New(t) r := require.New(t) - provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.userMapper, tt.fields.opts...) + provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.userMapper, http.DefaultClient, tt.fields.opts...) r.NoError(err) provider.generateVerifier = func() string { return "pkceOAuthVerifier" @@ -208,7 +209,7 @@ func TestProvider_Options(t *testing.T) { tt.fields.httpMock(tt.fields.issuer) a := assert.New(t) - provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.userMapper, tt.fields.opts...) + provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.userMapper, http.DefaultClient, tt.fields.opts...) require.NoError(t, err) a.Equal(tt.want.name, provider.Name()) diff --git a/internal/idp/providers/oidc/session_test.go b/internal/idp/providers/oidc/session_test.go index f79ac7906..ba58422e3 100644 --- a/internal/idp/providers/oidc/session_test.go +++ b/internal/idp/providers/oidc/session_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net/http" "testing" "time" @@ -325,7 +326,7 @@ func TestSession_FetchUser(t *testing.T) { tt.fields.httpMock(tt.fields.issuer) a := assert.New(t) - provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.userMapper, tt.opts...) + provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.userMapper, http.DefaultClient, tt.opts...) require.NoError(t, err) session := &Session{ diff --git a/internal/idp/providers/saml/saml.go b/internal/idp/providers/saml/saml.go index f3c4901bf..1c0527765 100644 --- a/internal/idp/providers/saml/saml.go +++ b/internal/idp/providers/saml/saml.go @@ -8,6 +8,7 @@ import ( "crypto/x509" "encoding/xml" "io" + "net/http" "net/url" "time" @@ -156,6 +157,7 @@ func New( metadata []byte, certificate []byte, key []byte, + httpClient *http.Client, options ...ProviderOpts, ) (*Provider, error) { entityDescriptor, err := ParseMetadata(metadata) @@ -180,6 +182,7 @@ func New( Certificate: keyPair.Leaf, IDPMetadata: entityDescriptor, SignRequest: false, + HTTPClient: httpClient, } provider := &Provider{ name: name, diff --git a/internal/idp/providers/saml/saml_test.go b/internal/idp/providers/saml/saml_test.go index eeb9a58d6..766f4abf5 100644 --- a/internal/idp/providers/saml/saml_test.go +++ b/internal/idp/providers/saml/saml_test.go @@ -3,6 +3,7 @@ package saml import ( "context" "encoding/xml" + "net/http" "net/url" "testing" "time" @@ -107,6 +108,7 @@ func TestProvider_BeginAuth(t *testing.T) { tt.fields.metadata, tt.fields.certificate, tt.fields.key, + http.DefaultClient, tt.fields.options..., ) require.NoError(t, err) @@ -285,7 +287,7 @@ func TestProvider_Options(t *testing.T) { t.Run(tt.name, func(t *testing.T) { a := assert.New(t) - provider, err := New(tt.fields.name, tt.fields.rootURL, tt.fields.metadata, tt.fields.certificate, tt.fields.key, tt.fields.options...) + provider, err := New(tt.fields.name, tt.fields.rootURL, tt.fields.metadata, tt.fields.certificate, tt.fields.key, http.DefaultClient, tt.fields.options...) if tt.want.err { require.Error(t, err) } else { diff --git a/internal/idp/providers/saml/session_test.go b/internal/idp/providers/saml/session_test.go index 18859fa69..8756b3b69 100644 --- a/internal/idp/providers/saml/session_test.go +++ b/internal/idp/providers/saml/session_test.go @@ -370,7 +370,7 @@ func TestSession_FetchUser(t *testing.T) { t.Run(tt.name, func(t *testing.T) { a := assert.New(t) - provider, err := New(tt.fields.name, tt.fields.rootURL, tt.fields.metadata, tt.fields.certificate, tt.fields.key, tt.fields.options...) + provider, err := New(tt.fields.name, tt.fields.rootURL, tt.fields.metadata, tt.fields.certificate, tt.fields.key, http.DefaultClient, tt.fields.options...) require.NoError(t, err) session, err := NewSession(provider, tt.args.requestID, tt.args.request) diff --git a/internal/net/net.go b/internal/net/net.go index 638e51ca3..b9b579e64 100644 --- a/internal/net/net.go +++ b/internal/net/net.go @@ -8,7 +8,7 @@ import ( type IPLookupFunc func(string) ([]builtin_net.IP, error) -// HostnameToIPList converts the input URL to a list [net.IP]. +// HostnameToIPList converts the input hostname to a list [net.IP]. // // Returns an internal error if the lookup fails. // Returns an invalid argument if the lookup function is nil diff --git a/internal/notification/channels/set/channel.go b/internal/notification/channels/set/channel.go index fbd406573..f60c19b5b 100644 --- a/internal/notification/channels/set/channel.go +++ b/internal/notification/channels/set/channel.go @@ -38,7 +38,7 @@ func InitChannel(ctx context.Context, cfg Config) (channels.NotificationChannel, return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) + resp, err := cfg.Client.Do(req) if err != nil { return err } diff --git a/internal/notification/channels/set/config.go b/internal/notification/channels/set/config.go index 5d28b3d11..326697c16 100644 --- a/internal/notification/channels/set/config.go +++ b/internal/notification/channels/set/config.go @@ -1,11 +1,13 @@ package set import ( + "net/http" "net/url" ) type Config struct { CallURL string + Client *http.Client } func (w *Config) Validate() error { diff --git a/internal/notification/channels/webhook/channel.go b/internal/notification/channels/webhook/channel.go index d532ac6cd..658defb2c 100644 --- a/internal/notification/channels/webhook/channel.go +++ b/internal/notification/channels/webhook/channel.go @@ -48,7 +48,7 @@ func InitChannel(ctx context.Context, cfg Config) (channels.NotificationChannel, req.Header.Set(SigningHeader, actions.ComputeSignatureHeader(time.Now(), []byte(payload), cfg.SigningKey)) } - resp, err := http.DefaultClient.Do(req) + resp, err := cfg.Client.Do(req) if err != nil { return err } diff --git a/internal/notification/channels/webhook/config.go b/internal/notification/channels/webhook/config.go index 5372cb763..e2047991f 100644 --- a/internal/notification/channels/webhook/config.go +++ b/internal/notification/channels/webhook/config.go @@ -10,6 +10,7 @@ type Config struct { Method string Headers http.Header SigningKey string + Client *http.Client } func (w *Config) Validate() error { diff --git a/internal/notification/handlers/back_channel_logout_worker.go b/internal/notification/handlers/back_channel_logout_worker.go index bc469060f..c3391fa4f 100644 --- a/internal/notification/handlers/back_channel_logout_worker.go +++ b/internal/notification/handlers/back_channel_logout_worker.go @@ -3,6 +3,7 @@ package handlers import ( "context" "errors" + "net/http" "time" "github.com/riverqueue/river" @@ -30,6 +31,7 @@ type BackChannelLogoutWorker struct { config *BackChannelLogoutWorkerConfig now nowFunc idGenerator id.Generator + httpClient *http.Client } // Timeout implements the Timeout-function of [river.Worker]. @@ -97,7 +99,8 @@ func (w *BackChannelLogoutWorker) sendLogoutRequest(ctx context.Context, request if err != nil { return err } - if err = types.SendSecurityTokenEvent(ctx, set.Config{CallURL: request.BackChannelLogoutURI}, w.channels, &LogoutTokenMessage{LogoutToken: token}, request.TriggeringEventType).WithoutTemplate(); err != nil { + backchannelNotifCfg := set.Config{CallURL: request.BackChannelLogoutURI, Client: w.httpClient} + if err = types.SendSecurityTokenEvent(ctx, backchannelNotifCfg, w.channels, &LogoutTokenMessage{LogoutToken: token}, request.TriggeringEventType).WithoutTemplate(); err != nil { return err } return w.commands.BackChannelLogoutSent(ctx, request.SessionID, request.OIDCSessionID, request.Aggregate.InstanceID) @@ -129,6 +132,7 @@ func NewBackChannelLogoutWorker( config *BackChannelLogoutWorkerConfig, idGenerator id.Generator, + httpClient *http.Client, ) *BackChannelLogoutWorker { return &BackChannelLogoutWorker{ commands: commands, @@ -139,6 +143,7 @@ func NewBackChannelLogoutWorker( config: config, now: time.Now, idGenerator: idGenerator, + httpClient: httpClient, } } diff --git a/internal/notification/handlers/back_channel_logout_worker_test.go b/internal/notification/handlers/back_channel_logout_worker_test.go index a994f6d0a..4ee169b0f 100644 --- a/internal/notification/handlers/back_channel_logout_worker_test.go +++ b/internal/notification/handlers/back_channel_logout_worker_test.go @@ -439,6 +439,7 @@ func newBackChannelLogoutWorker(queries Queries, commands Commands, es *eventsto nil, nil, nil, + nil, ), eventstore: es, queue: queue, diff --git a/internal/notification/handlers/config_email.go b/internal/notification/handlers/config_email.go index 5b78b39b3..81513e775 100644 --- a/internal/notification/handlers/config_email.go +++ b/internal/notification/handlers/config_email.go @@ -36,6 +36,7 @@ func (n *NotificationQueries) GetActiveEmailConfig(ctx context.Context) (*email. Method: http.MethodPost, Headers: nil, SigningKey: config.HTTPConfig.SigningKey, + Client: n.httpClient, }, }, nil } diff --git a/internal/notification/handlers/config_email_test.go b/internal/notification/handlers/config_email_test.go index 7da0c1c12..305f1d5db 100644 --- a/internal/notification/handlers/config_email_test.go +++ b/internal/notification/handlers/config_email_test.go @@ -1,6 +1,8 @@ package handlers import ( + "errors" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -11,33 +13,41 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/notification/channels/email" "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" "github.com/zitadel/zitadel/internal/notification/handlers/mock" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" ) func TestNotificationQueries_GetActiveEmailConfig(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - + t.Parallel() const instId = "instance-1" const cryptAlg = "alg1" const keyId = "key1" const pwd = "password" + const clientSecret = "client-secret" encryptedPwd := []byte("password-encrypted") - cryptAlgMock := crypto.NewMockEncryptionAlgorithm(ctrl) - cryptAlgMock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).Return(pwd, nil) - cryptAlgMock.EXPECT().Algorithm().Return(cryptAlg) - cryptAlgMock.EXPECT().DecryptionKeyIDs().Return([]string{keyId}) + encryptedClientSecret := []byte("client-secret-encrypted") + + var ( + errDecryptionFailed = errors.New("decryption failed") + errDatabaseError = errors.New("database error") + ) tt := []struct { - smtpConfig *query.SMTPConfig - expected *email.Config + name string + smtpConfig *query.SMTPConfig + smtpConfigErr error + setupCrypto func(m *crypto.MockEncryptionAlgorithm) + expected *email.Config + expectedErr error }{ { + name: "smtp config with plain auth (user and password)", smtpConfig: &query.SMTPConfig{ ID: "smtp1", - Description: "smtp config with authentication (user and password)", + Description: "smtp config with plain auth (user and password)", SMTPConfig: &query.SMTP{ TLS: true, SenderAddress: "sender@example.com", @@ -55,10 +65,15 @@ func TestNotificationQueries_GetActiveEmailConfig(t *testing.T) { }, }, }, + setupCrypto: func(m *crypto.MockEncryptionAlgorithm) { + m.EXPECT().Algorithm().Return(cryptAlg) + m.EXPECT().DecryptionKeyIDs().Return([]string{keyId}) + m.EXPECT().DecryptString(gomock.Any(), gomock.Any()).Return(pwd, nil) + }, expected: &email.Config{ ProviderConfig: &email.Provider{ ID: "smtp1", - Description: "smtp config with authentication (user and password)", + Description: "smtp config with plain auth (user and password)", }, SMTPConfig: &smtp.Config{ SMTP: smtp.SMTP{ @@ -76,9 +91,10 @@ func TestNotificationQueries_GetActiveEmailConfig(t *testing.T) { }, }, { + name: "smtp config with user but no plain auth config (fallback to plain auth without password)", smtpConfig: &query.SMTPConfig{ ID: "smtp2", - Description: "smtp config with authentication (user and no password)", + Description: "smtp config with user but no plain auth config (fallback to plain auth without password)", SMTPConfig: &query.SMTP{ TLS: true, SenderAddress: "sender@example.com", @@ -91,7 +107,7 @@ func TestNotificationQueries_GetActiveEmailConfig(t *testing.T) { expected: &email.Config{ ProviderConfig: &email.Provider{ ID: "smtp2", - Description: "smtp config with authentication (user and no password)", + Description: "smtp config with user but no plain auth config (fallback to plain auth without password)", }, SMTPConfig: &smtp.Config{ SMTP: smtp.SMTP{ @@ -108,6 +124,7 @@ func TestNotificationQueries_GetActiveEmailConfig(t *testing.T) { }, }, { + name: "smtp config without authentication", smtpConfig: &query.SMTPConfig{ ID: "smtp3", Description: "smtp config without authentication", @@ -135,18 +152,205 @@ func TestNotificationQueries_GetActiveEmailConfig(t *testing.T) { }, }, }, + { + name: "smtp config with xoauth2 auth and client credentials", + smtpConfig: &query.SMTPConfig{ + ID: "smtp4", + Description: "smtp config with xoauth2 auth and client credentials", + SMTPConfig: &query.SMTP{ + SenderAddress: "sender@example.com", + SenderName: "sender", + Host: "mail.com", + User: "mail-user", + XOAuth2Auth: &query.XOAuth2Auth{ + TokenEndpoint: "https://token.example.com/token", + Scopes: []string{"https://mail.example.com/.default"}, + ClientCredentials: &query.XOAuthClientCredentials{ + ClientId: "client-id", + ClientSecret: &crypto.CryptoValue{ + Algorithm: cryptAlg, + KeyID: keyId, + Crypted: encryptedClientSecret, + }, + }, + }, + }, + }, + setupCrypto: func(m *crypto.MockEncryptionAlgorithm) { + m.EXPECT().Algorithm().Return(cryptAlg) + m.EXPECT().DecryptionKeyIDs().Return([]string{keyId}) + m.EXPECT().DecryptString(gomock.Any(), gomock.Any()).Return(clientSecret, nil) + }, + expected: &email.Config{ + ProviderConfig: &email.Provider{ + ID: "smtp4", + Description: "smtp config with xoauth2 auth and client credentials", + }, + SMTPConfig: &smtp.Config{ + SMTP: smtp.SMTP{ + Host: "mail.com", + XOAuth2Auth: &smtp.XOAuth2AuthConfig{ + User: "mail-user", + TokenEndpoint: "https://token.example.com/token", + Scopes: []string{"https://mail.example.com/.default"}, + ClientCredentialsAuth: &smtp.OAuth2ClientCredentials{ + ClientId: "client-id", + ClientSecret: clientSecret, + }, + }, + }, + From: "sender@example.com", + FromName: "sender", + }, + }, + }, + { + name: "smtp config with xoauth2 auth without client credentials", + smtpConfig: &query.SMTPConfig{ + ID: "smtp5", + Description: "smtp config with xoauth2 auth without client credentials", + SMTPConfig: &query.SMTP{ + SenderAddress: "sender@example.com", + SenderName: "sender", + Host: "mail.com", + User: "mail-user", + XOAuth2Auth: &query.XOAuth2Auth{ + TokenEndpoint: "https://token.example.com/token", + Scopes: []string{"scope1"}, + }, + }, + }, + expected: &email.Config{ + ProviderConfig: &email.Provider{ + ID: "smtp5", + Description: "smtp config with xoauth2 auth without client credentials", + }, + SMTPConfig: &smtp.Config{ + SMTP: smtp.SMTP{ + Host: "mail.com", + XOAuth2Auth: &smtp.XOAuth2AuthConfig{ + User: "mail-user", + TokenEndpoint: "https://token.example.com/token", + Scopes: []string{"scope1"}, + }, + }, + From: "sender@example.com", + FromName: "sender", + }, + }, + }, + { + name: "plain auth password decryption error", + smtpConfig: &query.SMTPConfig{ + ID: "smtp6", + Description: "plain auth password decryption error", + SMTPConfig: &query.SMTP{ + Host: "mail.com", + User: "mail-user", + PlainAuth: &query.PlainAuth{ + Password: &crypto.CryptoValue{ + Algorithm: cryptAlg, + KeyID: keyId, + Crypted: encryptedPwd, + }, + }, + }, + }, + setupCrypto: func(m *crypto.MockEncryptionAlgorithm) { + m.EXPECT().Algorithm().Return(cryptAlg) + m.EXPECT().DecryptionKeyIDs().Return([]string{keyId}) + m.EXPECT().DecryptString(gomock.Any(), gomock.Any()).Return("", errDecryptionFailed) + }, + expectedErr: errDecryptionFailed, + }, + { + name: "xoauth2 client credentials decryption error", + smtpConfig: &query.SMTPConfig{ + ID: "smtp7", + Description: "xoauth2 client credentials decryption error", + SMTPConfig: &query.SMTP{ + Host: "mail.com", + User: "mail-user", + XOAuth2Auth: &query.XOAuth2Auth{ + TokenEndpoint: "https://token.example.com/token", + ClientCredentials: &query.XOAuthClientCredentials{ + ClientId: "client-id", + ClientSecret: &crypto.CryptoValue{ + CryptoType: 0, + Algorithm: cryptAlg, + KeyID: keyId, + Crypted: encryptedClientSecret, + }, + }, + }, + }, + }, + setupCrypto: func(m *crypto.MockEncryptionAlgorithm) { + m.EXPECT().Algorithm().Return(cryptAlg) + m.EXPECT().DecryptionKeyIDs().Return([]string{keyId}) + m.EXPECT().DecryptString(gomock.Any(), gomock.Any()).Return("", errDecryptionFailed) + }, + expectedErr: errDecryptionFailed, + }, + { + name: "http webhook config", + smtpConfig: &query.SMTPConfig{ + ID: "http1", + Description: "http webhook config", + HTTPConfig: &query.HTTP{ + Endpoint: "https://example.com/notify", + SigningKey: "my-signing-key", + }, + }, + expected: &email.Config{ + ProviderConfig: &email.Provider{ + ID: "http1", + Description: "http webhook config", + }, + WebhookConfig: &webhook.Config{ + CallURL: "https://example.com/notify", + Method: http.MethodPost, + SigningKey: "my-signing-key", + }, + }, + }, + { + name: "neither smtp nor http config set", + smtpConfig: &query.SMTPConfig{ + ID: "smtp8", + Description: "neither smtp nor http config set", + }, + expectedErr: zerrors.ThrowNotFound(nil, "", ""), + }, + { + name: "SMTPConfigActive query error", + smtpConfigErr: errDatabaseError, + expectedErr: errDatabaseError, + }, } for _, tc := range tt { - t.Run(tc.smtpConfig.Description, func(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) ctx := authz.NewMockContext(instId, "org-1", "user-1") - queryMock := mock.NewMockQueries(ctrl) - queryMock.EXPECT().SMTPConfigActive(gomock.Any(), instId).Return(tc.smtpConfig, nil) + cryptAlgMock := crypto.NewMockEncryptionAlgorithm(ctrl) + if tc.setupCrypto != nil { + tc.setupCrypto(cryptAlgMock) + } - notificationQueries := NewNotificationQueries(queryMock, &eventstore.Eventstore{}, "ext domain", uint16(1234), false, "filepath", nil, cryptAlgMock, nil) + queryMock := mock.NewMockQueries(ctrl) + queryMock.EXPECT().SMTPConfigActive(gomock.Any(), instId).Return(tc.smtpConfig, tc.smtpConfigErr) + + notificationQueries := NewNotificationQueries(queryMock, &eventstore.Eventstore{}, "ext domain", uint16(1234), false, "filepath", nil, cryptAlgMock, nil, nil) cfg, err := notificationQueries.GetActiveEmailConfig(ctx) - assert.NoError(t, err) + + assert.ErrorIs(t, err, tc.expectedErr) + if tc.expectedErr != nil { + return + } + assert.EqualValues(t, tc.expected, cfg) }) } diff --git a/internal/notification/handlers/config_sms.go b/internal/notification/handlers/config_sms.go index bdbc7f162..73b11c992 100644 --- a/internal/notification/handlers/config_sms.go +++ b/internal/notification/handlers/config_sms.go @@ -49,6 +49,7 @@ func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Conf Method: http.MethodPost, Headers: nil, SigningKey: config.HTTPConfig.SigningKey, + Client: n.httpClient, }, }, nil } diff --git a/internal/notification/handlers/config_sms_test.go b/internal/notification/handlers/config_sms_test.go new file mode 100644 index 000000000..adc0ba0de --- /dev/null +++ b/internal/notification/handlers/config_sms_test.go @@ -0,0 +1,176 @@ +package handlers + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/channels/sms" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/handlers/mock" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestNotificationQueries_GetActiveSMSConfig(t *testing.T) { + t.Parallel() + + const instId = "instance-1" + const cryptAlg = "alg1" + const keyId = "key1" + const token = "twilio-token" + + encryptedToken := []byte("twilio-token-encrypted") + + var ( + errDecryptionFailed = errors.New("decryption failed") + errDatabaseError = errors.New("database error") + ) + + tt := []struct { + name string + smsConfig *query.SMSConfig + smsConfigErr error + setupCrypto func(m *crypto.MockEncryptionAlgorithm) + expected *sms.Config + expectedErr error + }{ + { + name: "twilio config with token", + smsConfig: &query.SMSConfig{ + ID: "twilio1", + Description: "twilio config with token", + TwilioConfig: &query.Twilio{ + SID: "sid-1", + SenderNumber: "+10005550100", + VerifyServiceSID: "verify-sid-1", + Token: &crypto.CryptoValue{ + Algorithm: cryptAlg, + KeyID: keyId, + Crypted: encryptedToken, + }, + }, + }, + setupCrypto: func(m *crypto.MockEncryptionAlgorithm) { + m.EXPECT().Algorithm().Return(cryptAlg) + m.EXPECT().DecryptionKeyIDs().Return([]string{keyId}) + m.EXPECT().DecryptString(gomock.Any(), gomock.Any()).Return(token, nil) + }, + expected: &sms.Config{ + ProviderConfig: &sms.Provider{ + ID: "twilio1", + Description: "twilio config with token", + }, + TwilioConfig: &twilio.Config{ + SID: "sid-1", + Token: token, + SenderNumber: "+10005550100", + VerifyServiceSID: "verify-sid-1", + }, + }, + }, + { + name: "twilio config with nil token", + smsConfig: &query.SMSConfig{ + ID: "twilio2", + Description: "twilio config with nil token", + TwilioConfig: &query.Twilio{ + SID: "sid-2", + SenderNumber: "+10005550100", + }, + }, + expectedErr: zerrors.ThrowNotFound(nil, "", ""), + }, + { + name: "twilio token decryption error", + smsConfig: &query.SMSConfig{ + ID: "twilio3", + Description: "twilio token decryption error", + TwilioConfig: &query.Twilio{ + SID: "sid-3", + SenderNumber: "+10005550100", + Token: &crypto.CryptoValue{ + Algorithm: cryptAlg, + KeyID: keyId, + Crypted: encryptedToken, + }, + }, + }, + setupCrypto: func(m *crypto.MockEncryptionAlgorithm) { + m.EXPECT().Algorithm().Return(cryptAlg) + m.EXPECT().DecryptionKeyIDs().Return([]string{keyId}) + m.EXPECT().DecryptString(gomock.Any(), gomock.Any()).Return("", errDecryptionFailed) + }, + expectedErr: errDecryptionFailed, + }, + { + name: "http webhook config", + smsConfig: &query.SMSConfig{ + ID: "http1", + Description: "http webhook config", + HTTPConfig: &query.HTTP{ + Endpoint: "https://example.com/sms", + SigningKey: "my-signing-key", + }, + }, + expected: &sms.Config{ + ProviderConfig: &sms.Provider{ + ID: "http1", + Description: "http webhook config", + }, + WebhookConfig: &webhook.Config{ + CallURL: "https://example.com/sms", + Method: http.MethodPost, + Headers: nil, + SigningKey: "my-signing-key", + }, + }, + }, + { + name: "neither twilio nor http config set", + smsConfig: &query.SMSConfig{ + ID: "sms1", + Description: "neither twilio nor http config set", + }, + expectedErr: zerrors.ThrowNotFound(nil, "", ""), + }, + { + name: "SMSProviderConfigActive query error", + smsConfigErr: errDatabaseError, + expectedErr: errDatabaseError, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ctx := authz.NewMockContext(instId, "org-1", "user-1") + + cryptAlgMock := crypto.NewMockEncryptionAlgorithm(ctrl) + if tc.setupCrypto != nil { + tc.setupCrypto(cryptAlgMock) + } + + queryMock := mock.NewMockQueries(ctrl) + queryMock.EXPECT().SMSProviderConfigActive(gomock.Any(), instId).Return(tc.smsConfig, tc.smsConfigErr) + + notificationQueries := NewNotificationQueries(queryMock, &eventstore.Eventstore{}, "ext domain", uint16(1234), false, "filepath", nil, nil, cryptAlgMock, nil) + cfg, err := notificationQueries.GetActiveSMSConfig(ctx) + + assert.ErrorIs(t, err, tc.expectedErr) + if tc.expectedErr != nil { + return + } + + assert.EqualValues(t, tc.expected, cfg) + }) + } +} diff --git a/internal/notification/handlers/notification_worker_test.go b/internal/notification/handlers/notification_worker_test.go index 5c85a22a9..9b8ecb66f 100644 --- a/internal/notification/handlers/notification_worker_test.go +++ b/internal/notification/handlers/notification_worker_test.go @@ -433,6 +433,7 @@ func newNotificationWorker(t *testing.T, ctrl *gomock.Controller, queries *mock. f.userDataCrypto, smtpAlg, f.SMSTokenCrypto, + nil, ), channels: ¬ificationChannels{ Chain: *senders.ChainChannels(channel), diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index b06657ae9..2030c0947 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "net/http" "github.com/go-jose/go-jose/v4" "golang.org/x/text/language" @@ -44,6 +45,7 @@ type NotificationQueries struct { UserDataCrypto crypto.EncryptionAlgorithm SMTPPasswordCrypto crypto.EncryptionAlgorithm SMSTokenCrypto crypto.EncryptionAlgorithm + httpClient *http.Client } func NewNotificationQueries( @@ -56,6 +58,7 @@ func NewNotificationQueries( userDataCrypto crypto.EncryptionAlgorithm, smtpPasswordCrypto crypto.EncryptionAlgorithm, smsTokenCrypto crypto.EncryptionAlgorithm, + httpClient *http.Client, ) *NotificationQueries { return &NotificationQueries{ Queries: baseQueries, @@ -67,5 +70,6 @@ func NewNotificationQueries( UserDataCrypto: userDataCrypto, SMTPPasswordCrypto: smtpPasswordCrypto, SMSTokenCrypto: smsTokenCrypto, + httpClient: httpClient, } } diff --git a/internal/notification/handlers/quota_notifier.go b/internal/notification/handlers/quota_notifier.go index 365888959..b3d2a546d 100644 --- a/internal/notification/handlers/quota_notifier.go +++ b/internal/notification/handlers/quota_notifier.go @@ -71,7 +71,7 @@ func (u *quotaNotifier) reduceNotificationDue(event eventstore.Event) (*handler. if alreadyHandled { return nil } - err = types.SendJSON(ctx, webhook.Config{CallURL: e.CallURL, Method: http.MethodPost}, u.channels, e, e.Type()).WithoutTemplate() + err = types.SendJSON(ctx, webhook.Config{CallURL: e.CallURL, Method: http.MethodPost, Client: u.queries.httpClient}, u.channels, e, e.Type()).WithoutTemplate() if err != nil { return err } diff --git a/internal/notification/handlers/telemetry_pusher.go b/internal/notification/handlers/telemetry_pusher.go index 2c32db61c..4a7296d79 100644 --- a/internal/notification/handlers/telemetry_pusher.go +++ b/internal/notification/handlers/telemetry_pusher.go @@ -89,6 +89,7 @@ func (t *telemetryPusher) pushMilestone(ctx context.Context, e *milestone.Reache CallURL: endpoint, Method: http.MethodPost, Headers: t.cfg.Headers, + Client: t.queries.httpClient, }, t.channels, &struct { diff --git a/internal/notification/handlers/user_notifier_legacy_test.go b/internal/notification/handlers/user_notifier_legacy_test.go index 58cd08f54..26cf16f65 100644 --- a/internal/notification/handlers/user_notifier_legacy_test.go +++ b/internal/notification/handlers/user_notifier_legacy_test.go @@ -1972,6 +1972,7 @@ func newUserNotifierLegacy(t *testing.T, ctrl *gomock.Controller, queries *mock. f.userDataCrypto, smtpAlg, f.SMSTokenCrypto, + nil, ), otpEmailTmpl: func(origin *url.URL) string { return origin.String() + defaultOTPEmailTemplate diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go index f45ff7eea..c592494bc 100644 --- a/internal/notification/handlers/user_notifier_test.go +++ b/internal/notification/handlers/user_notifier_test.go @@ -1936,6 +1936,7 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu f.userDataCrypto, smtpAlg, f.SMSTokenCrypto, + nil, ), otpEmailTmpl: func(origin *url.URL) string { return origin.String() + defaultOTPEmailTemplate diff --git a/internal/notification/projections.go b/internal/notification/projections.go index 73f6dab2a..ffb4b4628 100644 --- a/internal/notification/projections.go +++ b/internal/notification/projections.go @@ -3,6 +3,7 @@ package notification import ( "context" "fmt" + "net/http" "net/url" "github.com/zitadel/logging" @@ -40,6 +41,7 @@ func Register( fileSystemPath string, userEncryption, smtpEncryption, smsEncryption crypto.EncryptionAlgorithm, queue *queue.Queue, + httpClient *http.Client, ) { if !notificationWorkerConfig.LegacyEnabled { queue.ShouldStart() @@ -48,7 +50,7 @@ func Register( // make sure the slice does not contain old values projections = nil - q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption) + q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption, httpClient) c := newChannels(q) projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl, notificationWorkerConfig, queue)) projections = append(projections, handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c)) @@ -59,7 +61,16 @@ func Register( queue, backChannelLogoutWorkerConfig.MaxAttempts, )) - queue.AddWorkers(ctx, handlers.NewBackChannelLogoutWorker(commands, q, es, queue, c, backChannelLogoutWorkerConfig, id.SonyFlakeGenerator())) + queue.AddWorkers(ctx, handlers.NewBackChannelLogoutWorker( + commands, + q, + es, + queue, + c, + backChannelLogoutWorkerConfig, + id.SonyFlakeGenerator(), + httpClient, + )) if telemetryCfg.Enabled { projections = append(projections, handlers.NewTelemetryPusher(ctx, telemetryCfg, projection.ApplyCustomConfig(telemetryHandlerCustomConfig), commands, q, c)) }