From 69c6508bc58fca519d2c71b4ab6461e4501c918d Mon Sep 17 00:00:00 2001 From: Tharindu Dharmarathna Date: Fri, 28 Aug 2026 14:30:04 +0530 Subject: [PATCH 1/2] event-gateway: shared httpclient, PQC cipher/curve config, and xDS mTLS support Brings event-gateway-controller/gateway-runtime onto the shared PQC-capable HTTP client and TLS configuration from PR #3222, including the gateway-controller core library changes (config, controlplane, policyxds, utils, api/handlers, xds, tlsauth) that event-gateway-controller directly depends on to build. --- .../gateway-controller/cmd/controller/main.go | 55 ++- .../cmd/event-gateway/plugins.go | 1 + .../gateway-runtime/configs/config.toml | 110 +++++ event-gateway/gateway-runtime/go.mod | 2 +- .../gateway-runtime/internal/config/config.go | 250 +++++++++++- .../internal/config/httpclient.go | 124 ++++++ .../internal/config/httpclient_test.go | 163 ++++++++ .../connectors/receiver/websub/connector.go | 17 +- .../connectors/receiver/websub/delivery.go | 36 +- .../connectors/receiver/websub/handler.go | 13 +- .../receiver/websub/verification.go | 31 +- .../internal/runtime/runtime.go | 115 +++++- .../internal/runtime/runtime_test.go | 125 +++++- .../gateway-controller/cmd/controller/main.go | 123 ++++-- .../cmd/controller/server_tls.go | 57 +++ .../pkg/api/handlers/handlers.go | 10 +- .../pkg/api/handlers/handlers_test.go | 4 +- .../gateway-controller/pkg/config/config.go | 358 +++++++++++++++-- .../pkg/config/config_test.go | 356 ++++++++++++++++- .../pkg/config/httpclient_config.go | 115 ++++++ .../pkg/config/server_tls.go | 95 +++++ .../gateway-controller/pkg/config/xds_tls.go | 184 +++++++++ .../pkg/config/xds_tls_test.go | 375 ++++++++++++++++++ .../pkg/controlplane/client.go | 104 +++-- .../controlplane/client_integration_test.go | 22 +- .../pkg/controlplane/controlplane_test.go | 2 +- .../pkg/controlplane/llm_deletion_test.go | 2 +- .../pkg/controlplane/sync.go | 6 +- .../pkg/policyxds/server.go | 95 +++-- .../pkg/policyxds/server_test.go | 43 +- .../pkg/tlsauth/peer_identity.go | 78 ++++ .../pkg/tlsauth/peer_identity_test.go | 131 ++++++ .../pkg/utils/api_deployment.go | 8 +- .../gateway-controller/pkg/utils/api_utils.go | 109 +++-- .../pkg/utils/api_utils_test.go | 42 +- .../pkg/utils/on_prem_apim_utils.go | 165 ++++---- .../utils/replica_sync_dependencies_test.go | 2 +- .../utils/replica_sync_test_helpers_test.go | 17 + gateway/gateway-controller/pkg/xds/server.go | 108 ++++- .../gateway-controller/pkg/xds/snapshot.go | 8 +- .../gateway-controller/pkg/xds/translator.go | 132 +++--- .../pkg/xds/translator_test.go | 28 +- .../tests/integration/vhost_test.go | 2 +- 43 files changed, 3318 insertions(+), 505 deletions(-) create mode 100644 event-gateway/gateway-runtime/internal/config/httpclient.go create mode 100644 event-gateway/gateway-runtime/internal/config/httpclient_test.go create mode 100644 gateway/gateway-controller/cmd/controller/server_tls.go create mode 100644 gateway/gateway-controller/pkg/config/httpclient_config.go create mode 100644 gateway/gateway-controller/pkg/config/server_tls.go create mode 100644 gateway/gateway-controller/pkg/config/xds_tls.go create mode 100644 gateway/gateway-controller/pkg/config/xds_tls_test.go create mode 100644 gateway/gateway-controller/pkg/tlsauth/peer_identity.go create mode 100644 gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go diff --git a/event-gateway/gateway-controller/cmd/controller/main.go b/event-gateway/gateway-controller/cmd/controller/main.go index dcee5ace3b..4bcc0c66fb 100644 --- a/event-gateway/gateway-controller/cmd/controller/main.go +++ b/event-gateway/gateway-controller/cmd/controller/main.go @@ -44,6 +44,7 @@ import ( "github.com/wso2/api-platform/common/eventhub" commonmodels "github.com/wso2/api-platform/common/models" "github.com/wso2/api-platform/common/webhooksecret" + "github.com/wso2/api-platform/httpkit/httpclient" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/adminserver" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/handlers" @@ -460,7 +461,12 @@ func main() { serverOpts := []policyxds.ServerOption{policyxds.WithOnFirstConnect(policyEngineConnected)} if cfg.Controller.PolicyServer.TLS.Enabled { - serverOpts = append(serverOpts, policyxds.WithTLS(cfg.Controller.PolicyServer.TLS.CertFile, cfg.Controller.PolicyServer.TLS.KeyFile)) + policyXDSTLSConfig, err := coreconfig.BuildXDSServerTLSConfig(cfg.Controller.PolicyServer.TLS) + if err != nil { + log.Error("invalid policy_server.tls config, refusing to start policy xDS server in plaintext", slog.Any("error", err)) + os.Exit(1) + } + serverOpts = append(serverOpts, policyxds.WithMTLS(policyXDSTLSConfig, cfg.Controller.PolicyServer.TLS.AllowedClientIdentities)) } policyXDSServer := policyxds.NewServer(policySnapshotManager, apiKeySnapshotManager, lazyResourceSnapshotManager, subscriptionSnapshotManager, webhookSecretSnapshotManager, cfg.Controller.PolicyServer.Port, log, serverOpts...) go func() { @@ -481,7 +487,21 @@ func main() { policyValidator := coreconfig.NewPolicyValidator(policyDefinitions) validator.SetPolicyValidator(policyValidator) - apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService) + // Build the single shared outbound *http.Client used by every control-plane / + // platform-API call this process makes, built once here and injected into every + // constructor below instead of each one building (or caching) its own. + httpClientCfg, err := coreconfig.BuildHTTPClientConfig(cfg.Controller.HTTPClient, cfg.Controller.ControlPlane.InsecureSkipVerify) + if err != nil { + log.Error("Invalid controller.http_client configuration", slog.Any("error", err)) + os.Exit(1) + } + httpClient, err := httpclient.New(httpClientCfg) + if err != nil { + log.Error("Failed to build HTTP client", slog.Any("error", err)) + os.Exit(1) + } + + apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService, httpClient) mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService) llmSvc := utils.NewLLMDeploymentService(configStore, db, snapshotManager, lazyResourceXDSManager, templateDefinitions, apiSvc, &cfg.Router, policyVersionResolver, policyValidator) @@ -489,7 +509,7 @@ func main() { cfg.Controller.ControlPlane, log, configStore, db, snapshotManager, validator, &cfg.Router, apiKeyXDSManager, apiKeyStore, &cfg.APIKey, policyManager, cfg, policyDefinitions, lazyResourceXDSManager, templateDefinitions, subscriptionSnapshotManager, eventHubInstance, - secretsService, webhookSecretStore, webhookSecretSnapshotManager, + secretsService, webhookSecretStore, webhookSecretSnapshotManager, httpClient, ) cpClient.SetControlPlaneEventGatewayHooks(controlplanehooks.Hooks{}) if err := cpClient.Start(); err != nil { @@ -499,8 +519,6 @@ func main() { llmSvc.SetControlPlanePusher(cpClient, cfg.Controller.ControlPlane.DeploymentSyncEnabled) mcpSvc.SetControlPlanePusher(cpClient, cfg.Controller.ControlPlane.DeploymentSyncEnabled) - httpClient := &http.Client{Timeout: 10 * time.Second} - restAPIService := restapi.NewRestAPIService( configStore, db, snapshotManager, policyManager, apiSvc, apiKeyXDSManager, cpClient, &cfg.Router, cfg, httpClient, coreconfig.NewParser(), validator, log, @@ -544,11 +562,15 @@ func main() { os.Exit(1) } - apiServer := handlers.NewAPIServer( + apiServer, err := handlers.NewAPIServer( configStore, db, snapshotManager, policyManager, lazyResourceXDSManager, log, cpClient, policyDefinitions, templateDefinitions, validator, apiKeyXDSManager, cfg, eventHubInstance, - subscriptionSnapshotManager, secretsService, restAPIService, + subscriptionSnapshotManager, secretsService, restAPIService, httpClient, ) + if err != nil { + log.Error("Failed to create API server", slog.Any("error", err)) + os.Exit(1) + } eventGatewayHandler := handler.NewWebSubServer(handler.Deps{ Store: configStore, @@ -627,7 +649,7 @@ func main() { var controllerAdminServer *adminserver.Server if cfg.Controller.AdminServer.Enabled { adminAuthz := authenticators.AuthorizationMiddleware( - commonmodels.AuthConfig{ResourceRoles: adminResourceRoles()}, log) + commonmodels.AuthConfig{ResourceRoles: adminResourceRoles(), HTTPClient: httpClient}, log) adminProtect := func(next http.Handler) http.Handler { return authMiddleWare(adminAuthz(next)) } @@ -810,9 +832,16 @@ func generateAuthConfig(cfg *coreconfig.Config) (commonmodels.AuthConfig, error) }, nil } +// adminResourceRoles builds the deny-by-default scope map for the controller +// admin/debug server (config_dump, xds_sync_status, pprof), gating every +// endpoint except the public health probe behind the "admin" role. Mirrors +// gateway-controller's core main.go adminResourceRoles implementation. func adminResourceRoles() map[string][]string { const adminRole = "admin" + // prefixed builds a resource key of the form " " + // matching the versioned routes registered via HandlerWithOptions(BaseURL=AdminAPIBasePath), + // mirroring the prefixed helper in generateAuthConfig. prefixed := func(methodAndPath string) string { idx := strings.Index(methodAndPath, " ") if idx < 0 { @@ -826,8 +855,10 @@ func adminResourceRoles() map[string][]string { "GET /xds_sync_status": {adminRole}, } - // pprof endpoints are registered directly on the mux rather than under a - // BaseURL, so their pattern carries no method and no base-path prefix. + // pprof endpoints are registered directly on the mux (not via a BaseURL), so + // their r.Pattern carries no method and no base-path prefix — map them as-is. + // Only registered when admin_server.pprof is enabled, but mapping them + // unconditionally is harmless and keeps them admin-gated when it is. pprofRoles := map[string][]string{ "/debug/pprof/": {adminRole}, "/debug/pprof/cmdline": {adminRole}, @@ -836,8 +867,8 @@ func adminResourceRoles() map[string][]string { "/debug/pprof/trace": {adminRole}, } - // The admin API is served on both the versioned and the legacy unprefixed - // paths, so both keys are needed for the authz middleware to match. + // Populate both the versioned and legacy (unprefixed) keys for each relative + // route so the authz middleware matches either form, exactly as generateAuthConfig does. resourceRoles := make(map[string][]string, len(relativeRoles)*2+len(pprofRoles)) for methodAndPath, roles := range relativeRoles { resourceRoles[prefixed(methodAndPath)] = roles diff --git a/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go b/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go index 33cfac5226..84e7096eed 100644 --- a/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go +++ b/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go @@ -51,6 +51,7 @@ func registerConnectors(registry *connectors.Registry, cfg *config.Config) { DeliveryConcurrency: cfg.WebSub.DeliveryConcurrency, RuntimeID: cfg.RuntimeID, ConsumerGroupPrefix: cfg.Kafka.ConsumerGroupPrefix, + HTTPClient: cfg.HTTPClient, }) }) diff --git a/event-gateway/gateway-runtime/configs/config.toml b/event-gateway/gateway-runtime/configs/config.toml index 177bd31058..78b78905fe 100644 --- a/event-gateway/gateway-runtime/configs/config.toml +++ b/event-gateway/gateway-runtime/configs/config.toml @@ -18,6 +18,17 @@ websub_https_port = 8443 websub_tls_enabled = true websub_tls_cert_file = "/etc/event-gateway/tls/default-listener.crt" websub_tls_key_file = "/etc/event-gateway/tls/default-listener.key" +# Inbound TLS tuning for the WebSub-HTTPS listener. All four fields are optional — +# empty uses Go's crypto/tls defaults — but min/max version must both be set or both +# left empty. Same vocabulary as http_client.tls above. +websub_tls_min_version = "TLS1_2" +websub_tls_max_version = "TLS1_3" +# Hybrid PQC group listed first with classical fallbacks after — a peer that doesn't +# yet support X25519MLKEM768 still succeeds via the later entries. +websub_tls_curve_preferences = "X25519MLKEM768,X25519,P-256" +# Comma-separated Go crypto/tls cipher suite names; only affects TLS 1.2 and below. +# Empty uses Go's own default secure set. +websub_tls_cipher_suites = "" websocket_port = 8081 # HTTPS port for WebSocket server (used when TLS is enabled for WebBrokerApi) websocket_https_port = 8444 @@ -25,8 +36,20 @@ websocket_https_port = 8444 websocket_tls_enabled = true websocket_tls_cert_file = "/etc/event-gateway/tls/default-listener.crt" websocket_tls_key_file = "/etc/event-gateway/tls/default-listener.key" +# Inbound TLS tuning for the WebSocket-HTTPS listener — same shape as websub_tls_* above. +websocket_tls_min_version = "TLS1_2" +websocket_tls_max_version = "TLS1_3" +websocket_tls_curve_preferences = "X25519MLKEM768,X25519,P-256" +websocket_tls_cipher_suites = "" admin_port = 9002 metrics_port = 9003 +# Read/write/idle timeouts and max header size for the WebSub and WebSocket managed +# HTTP(S) servers. All four must be positive — they bound how long a connection can be +# held open by a slow or malicious client (Slowloris-style resource exhaustion). +read_timeout = "30s" +write_timeout = "60s" +idle_timeout = "120s" +max_header_bytes = 1048576 [kafka] # Default Kafka brokers. Channels can override with broker-driver.config.brokers. @@ -59,6 +82,93 @@ subscriptions_topic_name = "__subscriptions" # config_file = "" # chains_file = "" +# Shared HTTP client configuration for WebSub's outbound calls to subscriber-supplied +# CallbackURLs: intent verification (subscribe/unsubscribe challenge — see +# internal/connectors/receiver/websub/verification.go) and message delivery (see +# .../delivery.go). Both clients share this same pooling/TLS/proxy/SSRF posture; each +# keeps its own existing timeout (websub.verification_timeout_seconds above for +# verification; a fixed 30s delivery timeout for delivery) which always overrides +# http_client.timeouts.overall below for that client. +# +# SSRF guarding is ALWAYS enabled for both clients and is not configurable off: every +# CallbackURL dialed here is tenant/subscriber-supplied, exactly the scenario +# ssrf-prevention.md targets. netguard.PermitPrivateBlockMetadata() is applied +# unconditionally in code (private/loopback backends stay reachable — in-cluster +# subscribers are the common case — while link-local/metadata/unspecified/multicast +# destinations are refused at dial time). Only the redirect/scheme knobs below +# (http_client.ssrf) are configurable. + +[http_client.pooling] +max_idle_conns = 100 +max_idle_conns_per_host = 10 +max_conns_per_host = 100 +idle_conn_timeout = "90s" +keep_alive = "30s" +disable_keep_alives = false +# HTTP/2 is off by default: this client always sets a custom TLS config, which makes Go's +# Transport conservatively disable HTTP/2 unless re-enabled here — only opt in after +# considering the connection-coalescing caveat in httpclient.PoolingConfig.EnableHTTP2's doc. +enable_http2 = false + +[http_client.timeouts] +# Common default only: the Verifier and Deliverer each override this with their own +# existing per-call-site timeout — see this section's header comment above. +overall = "30s" +dial = "10s" +tls_handshake = "10s" +response_header = "10s" +expect_continue = "1s" +# 0 = package default (10MiB). A negative value is rejected at startup: unlike the +# general-purpose httpkit default, this client only ever reads tenant/subscriber-supplied +# callback responses, so disabling the bound entirely is never a supported option. +max_response_bytes = 0 + +[http_client.tls] +min_version = "TLS1_2" +max_version = "TLS1_3" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. Hybrid PQC group +# listed first with classical fallbacks after — a peer that doesn't yet support +# X25519MLKEM768 still succeeds via the later entries. +curve_preferences = "X25519MLKEM768,X25519,P-256" +# Comma-separated Go crypto/tls cipher suite names; only affects TLS 1.2 and below. Empty +# uses Go's own default secure set. +cipher_suites = "" +# PEM CA bundle / mTLS client cert+key for the outbound call to the subscriber callback. +# Empty uses the system root pool and no client certificate. +root_ca_file = "" +client_cert_file = "" +client_key_file = "" + +[http_client.proxy] +# "none" (default) | "environment" (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) | "url" +mode = "none" +url = "" +username = "" +password = "" +no_proxy = [] +# Required ("delegated" or "manual_connect") whenever mode != "none" — SSRF guarding is +# always enabled for this client, see this section's header comment above. +egress = "" + +[http_client.proxy.tls] +# Configures a SEPARATE TLS handshake to an https:// proxy itself, decoupled from +# http_client.tls above (which always governs the origin handshake). +root_ca_file = "" +client_cert_file = "" +client_key_file = "" +insecure_skip_verify = false +# Must ALSO be set to true, independent of insecure_skip_verify above, before disabling +# proxy TLS verification takes effect — a deliberate second flag so one setting can't +# silently satisfy its own safety acknowledgement. +insecure_skip_verify_acknowledged = false + +[http_client.ssrf] +# SSRF guarding itself is always enabled in code (not configurable off — see this +# section's header comment above); only the redirect/scheme knobs are exposed here. +# 0 uses netguard's own default (5 redirects); empty allowed_schemes defaults to {"https"}. +max_redirects = 0 +allowed_schemes = [] + [logging] level = "info" format = "text" diff --git a/event-gateway/gateway-runtime/go.mod b/event-gateway/gateway-runtime/go.mod index 73d0fa5982..4c7824a7cb 100644 --- a/event-gateway/gateway-runtime/go.mod +++ b/event-gateway/gateway-runtime/go.mod @@ -15,6 +15,7 @@ require ( github.com/wso2/api-platform/common v0.0.0 github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine v0.0.0-00010101000000-000000000000 github.com/wso2/api-platform/sdk/core v0.4.0 + github.com/wso2/api-platform/httpkit v0.0.0-local google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -46,7 +47,6 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect - github.com/wso2/api-platform/httpkit v0.0.0-local // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect diff --git a/event-gateway/gateway-runtime/internal/config/config.go b/event-gateway/gateway-runtime/internal/config/config.go index a5aaf4666a..2711a4813b 100644 --- a/event-gateway/gateway-runtime/internal/config/config.go +++ b/event-gateway/gateway-runtime/internal/config/config.go @@ -24,11 +24,14 @@ import ( "os" "strconv" "strings" + "time" "github.com/knadh/koanf/parsers/toml/v2" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" + + "github.com/wso2/api-platform/httpkit/tlsconfig" ) // Config is the top-level runtime configuration for the event gateway. @@ -39,24 +42,43 @@ type Config struct { PolicyEngine PolicyEngineConfig `koanf:"policy_engine"` ControlPlane ControlPlaneConfig `koanf:"controlplane"` Logging LoggingConfig `koanf:"logging"` + HTTPClient HTTPClientConfig `koanf:"http_client"` RuntimeID string `koanf:"runtime_id"` } // ServerConfig holds HTTP/WS server settings. type ServerConfig struct { - WebSubEnabled bool `koanf:"websub_enabled"` - WebSubHTTPPort int `koanf:"websub_http_port"` - WebSubHTTPSPort int `koanf:"websub_https_port"` - WebSubTLSEnabled bool `koanf:"websub_tls_enabled"` - WebSubTLSCertFile string `koanf:"websub_tls_cert_file"` - WebSubTLSKeyFile string `koanf:"websub_tls_key_file"` - WebSocketPort int `koanf:"websocket_port"` - WebSocketHTTPSPort int `koanf:"websocket_https_port"` - WebSocketTLSEnabled bool `koanf:"websocket_tls_enabled"` - WebSocketTLSCertFile string `koanf:"websocket_tls_cert_file"` - WebSocketTLSKeyFile string `koanf:"websocket_tls_key_file"` - AdminPort int `koanf:"admin_port"` - MetricsPort int `koanf:"metrics_port"` + WebSubEnabled bool `koanf:"websub_enabled"` + WebSubHTTPPort int `koanf:"websub_http_port"` + WebSubHTTPSPort int `koanf:"websub_https_port"` + WebSubTLSEnabled bool `koanf:"websub_tls_enabled"` + WebSubTLSCertFile string `koanf:"websub_tls_cert_file"` + WebSubTLSKeyFile string `koanf:"websub_tls_key_file"` + WebSubTLSMinVersion string `koanf:"websub_tls_min_version"` + WebSubTLSMaxVersion string `koanf:"websub_tls_max_version"` + WebSubTLSCipherSuites string `koanf:"websub_tls_cipher_suites"` + WebSubTLSCurvePreferences string `koanf:"websub_tls_curve_preferences"` + WebSocketPort int `koanf:"websocket_port"` + WebSocketHTTPSPort int `koanf:"websocket_https_port"` + WebSocketTLSEnabled bool `koanf:"websocket_tls_enabled"` + WebSocketTLSCertFile string `koanf:"websocket_tls_cert_file"` + WebSocketTLSKeyFile string `koanf:"websocket_tls_key_file"` + WebSocketTLSMinVersion string `koanf:"websocket_tls_min_version"` + WebSocketTLSMaxVersion string `koanf:"websocket_tls_max_version"` + WebSocketTLSCipherSuites string `koanf:"websocket_tls_cipher_suites"` + WebSocketTLSCurvePreferences string `koanf:"websocket_tls_curve_preferences"` + AdminPort int `koanf:"admin_port"` + MetricsPort int `koanf:"metrics_port"` + + // ReadTimeout, WriteTimeout, and IdleTimeout bound the WebSub/WebSocket + // managed HTTP(S) servers (see newManagedServer) so a slow or malicious + // client can't hold a connection open indefinitely (Slowloris-style + // resource exhaustion). MaxHeaderBytes bounds header size the same way. + // All four must be non-zero — DefaultConfig supplies safe defaults. + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + MaxHeaderBytes int `koanf:"max_header_bytes"` } // KafkaConfig holds Kafka connection settings. @@ -103,6 +125,130 @@ type LoggingConfig struct { Format string `koanf:"format"` } +// HTTPClientConfig configures the shared outbound *http.Client used by WebSub's +// subscribe/unsubscribe intent verification (Verifier, see +// internal/connectors/receiver/websub/verification.go) and message delivery +// (Deliverer, see .../delivery.go). Both call sites dial a tenant/subscriber-supplied +// CallbackURL and share the identical pooling/TLS/proxy/SSRF posture, so a single +// [http_client] TOML section configures both; each call site still keeps its own +// existing timeout parameter/field (Verifier's `timeout` argument, sourced from +// websub.verification_timeout_seconds; Deliverer's own delivery timeout), which always +// overrides Timeouts.Overall below for that specific client — see BuildHTTPClientConfig. +// +// This mirrors the TOML-expressible subset of +// github.com/wso2/api-platform/httpkit/httpclient.Config (see gateway-controller's +// pkg/config.HTTPClientConfig for the reference shape this intentionally mirrors +// field-for-field, aside from SSRF below). Go-only callback hooks +// (GetClientCertificate, VerifyPeerCertificate, VerifyConnection, ConnectHeader) and a +// pre-built *x509.CertPool have no TOML shape and are not represented here. +// +// Unlike gateway-controller's HTTPClientConfig, SSRF protection is NOT configurable +// off: every CallbackURL dialed by either client is tenant/subscriber-supplied — +// exactly the scenario ssrf-prevention.md targets — so SSRF.Enabled=true and +// netguard.PublicOnly() are hardcoded in BuildHTTPClientConfig rather than sourced +// from this struct. PublicOnly (not PermitPrivateBlockMetadata) is deliberate: a +// tenant-supplied CallbackURL must never be usable to reach an operator's own +// private/loopback network, only the public internet. Only the redirect/scheme +// knobs netguard exposes are configurable, via HTTPClientSSRFConfig. +type HTTPClientConfig struct { + Pooling HTTPClientPoolingConfig `koanf:"pooling"` + Timeouts HTTPClientTimeoutsConfig `koanf:"timeouts"` + TLS HTTPClientTLSConfig `koanf:"tls"` + Proxy HTTPClientProxyConfig `koanf:"proxy"` + SSRF HTTPClientSSRFConfig `koanf:"ssrf"` +} + +// HTTPClientPoolingConfig mirrors httpclient.PoolingConfig. +type HTTPClientPoolingConfig struct { + MaxIdleConns int `koanf:"max_idle_conns"` + MaxIdleConnsPerHost int `koanf:"max_idle_conns_per_host"` + MaxConnsPerHost int `koanf:"max_conns_per_host"` + IdleConnTimeout time.Duration `koanf:"idle_conn_timeout"` + KeepAlive time.Duration `koanf:"keep_alive"` + DisableKeepAlives bool `koanf:"disable_keep_alives"` + // EnableHTTP2 opts into HTTP/2. See httpclient.PoolingConfig.EnableHTTP2's doc comment + // on the HTTP/2 connection-coalescing caveat before enabling. + EnableHTTP2 bool `koanf:"enable_http2"` +} + +// HTTPClientTimeoutsConfig mirrors httpclient.TimeoutsConfig. Overall is only a common +// default shared by both call sites — see HTTPClientConfig's doc comment for why each +// call site's own existing timeout always takes precedence over it. +type HTTPClientTimeoutsConfig struct { + Overall time.Duration `koanf:"overall"` + Dial time.Duration `koanf:"dial"` + TLSHandshake time.Duration `koanf:"tls_handshake"` + ResponseHeader time.Duration `koanf:"response_header"` + ExpectContinue time.Duration `koanf:"expect_continue"` + // MaxResponseBytes bounds a callback response body. 0 = package default + // (10MiB). Unlike httpclient.TimeoutsConfig.MaxResponseBytes, a negative + // value here is rejected by BuildHTTPClientConfig rather than disabling + // the bound: every response body this client reads comes from a + // tenant/subscriber-supplied CallbackURL, so an unbounded read is never + // an acceptable opt-in for this client (see file-access.md directive 5). + MaxResponseBytes int64 `koanf:"max_response_bytes"` +} + +// HTTPClientTLSConfig mirrors the TOML-expressible subset of httpclient.TLSConfig. +type HTTPClientTLSConfig struct { + MinVersion string `koanf:"min_version"` // one of "TLS1_0".."TLS1_3" + MaxVersion string `koanf:"max_version"` // one of "TLS1_0".."TLS1_3" + CipherSuites string `koanf:"cipher_suites"` // comma-separated Go crypto/tls cipher suite names; TLS 1.2 and below only + CurvePreferences string `koanf:"curve_preferences"` // comma-separated, e.g. "X25519MLKEM768,X25519,P-256" + RootCAFile string `koanf:"root_ca_file"` // PEM CA bundle; empty uses the system root pool + ClientCertFile string `koanf:"client_cert_file"` // mTLS to the callback endpoint; both cert and key must be set together + ClientKeyFile string `koanf:"client_key_file"` +} + +// HTTPClientProxyConfig mirrors the TOML-expressible subset of httpclient.ProxyConfig. +type HTTPClientProxyConfig struct { + // Mode selects how the proxy is determined: "none" (default), "environment" + // (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), or "url" (URL/Username/Password/NoProxy below). + Mode string `koanf:"mode"` + URL string `koanf:"url"` + Username string `koanf:"username"` + Password string `koanf:"password"` + NoProxy []string `koanf:"no_proxy"` // exact host, ".suffix", or CIDR entries; only used when mode == "url" + + TLS HTTPClientProxyTLSConfig `koanf:"tls"` + + // Egress states how origin-destination SSRF risk is handled when a forward proxy is + // also configured: "delegated" (trust the proxy's own egress controls) or + // "manual_connect" (validate the origin locally before ever issuing CONNECT). Must be + // set explicitly whenever Mode != "none" — SSRF guarding is always enabled for this + // client (see HTTPClientConfig's doc comment), unlike gateway-controller where this + // is only required when SSRF is ALSO enabled. BuildHTTPClientConfig fails closed at + // config-build time otherwise rather than silently choosing one. + Egress string `koanf:"egress"` +} + +// HTTPClientProxyTLSConfig mirrors httpclient.ProxyTLSConfig (the proxy's own TLS +// handshake, fully decoupled from the origin TLS handshake in HTTPClientTLSConfig). +type HTTPClientProxyTLSConfig struct { + RootCAFile string `koanf:"root_ca_file"` + ClientCertFile string `koanf:"client_cert_file"` + ClientKeyFile string `koanf:"client_key_file"` + // InsecureSkipVerify and InsecureSkipVerifyAcknowledged are deliberately + // separate fields: httpkit's own acknowledgement gate + // (httpclient.ProxyTLSConfig, see tls.go) requires an operator to opt + // into disabling verification twice, once per field, so a single + // "insecure_skip_verify = true" in TOML can't silently satisfy its own + // gate. Both must be explicitly set to true for InsecureSkipVerify to + // take effect. + InsecureSkipVerify bool `koanf:"insecure_skip_verify"` + InsecureSkipVerifyAcknowledged bool `koanf:"insecure_skip_verify_acknowledged"` +} + +// HTTPClientSSRFConfig mirrors the TOML-expressible redirect/scheme knobs of +// httpclient.SSRFConfig. It deliberately has NO Enabled/Preset field: SSRF guarding for +// this client is always on with netguard.PublicOnly() (see HTTPClientConfig's doc +// comment) — there is no supported way to disable it, or to permit private/loopback +// destinations, via config. +type HTTPClientSSRFConfig struct { + MaxRedirects int `koanf:"max_redirects"` // 0 uses netguard's own default (5) + AllowedSchemes []string `koanf:"allowed_schemes"` // empty defaults to {"https"} +} + // DefaultConfig returns configuration with sensible defaults. func DefaultConfig() *Config { return &Config{ @@ -114,6 +260,10 @@ func DefaultConfig() *Config { WebSocketHTTPSPort: 8444, AdminPort: 9002, MetricsPort: 9003, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1 MiB }, Kafka: KafkaConfig{ Brokers: []string{"localhost:9092"}, @@ -132,6 +282,30 @@ func DefaultConfig() *Config { Level: "info", Format: "text", }, + HTTPClient: HTTPClientConfig{ + Pooling: HTTPClientPoolingConfig{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + MaxConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + KeepAlive: 30 * time.Second, + }, + Timeouts: HTTPClientTimeoutsConfig{ + Overall: 30 * time.Second, // common default only; each call site's own timeout overrides this + Dial: 10 * time.Second, + TLSHandshake: 10 * time.Second, + ResponseHeader: 10 * time.Second, + ExpectContinue: 1 * time.Second, + }, + TLS: HTTPClientTLSConfig{ + MinVersion: "TLS1_2", + MaxVersion: "TLS1_3", + CurvePreferences: "X25519MLKEM768,X25519,P-256", + }, + Proxy: HTTPClientProxyConfig{ + Mode: "none", + }, + }, } } @@ -270,6 +444,10 @@ func validate(cfg *Config) error { return err } + if err := validateServerTimeouts(cfg.Server); err != nil { + return err + } + if cfg.Server.WebSubTLSEnabled { if err := validateReadableFile(cfg.Server.WebSubTLSCertFile, "server.websub_tls_cert_file", "server.websub_tls_enabled"); err != nil { return err @@ -277,6 +455,9 @@ func validate(cfg *Config) error { if err := validateReadableFile(cfg.Server.WebSubTLSKeyFile, "server.websub_tls_key_file", "server.websub_tls_enabled"); err != nil { return err } + if err := validateListenerTLSTuning("server.websub_tls", cfg.Server.WebSubTLSMinVersion, cfg.Server.WebSubTLSMaxVersion, cfg.Server.WebSubTLSCipherSuites, cfg.Server.WebSubTLSCurvePreferences); err != nil { + return err + } } if cfg.Server.WebSocketTLSEnabled { @@ -286,6 +467,9 @@ func validate(cfg *Config) error { if err := validateReadableFile(cfg.Server.WebSocketTLSKeyFile, "server.websocket_tls_key_file", "server.websocket_tls_enabled"); err != nil { return err } + if err := validateListenerTLSTuning("server.websocket_tls", cfg.Server.WebSocketTLSMinVersion, cfg.Server.WebSocketTLSMaxVersion, cfg.Server.WebSocketTLSCipherSuites, cfg.Server.WebSocketTLSCurvePreferences); err != nil { + return err + } } switch cfg.Logging.Level { @@ -307,6 +491,25 @@ func validate(cfg *Config) error { return nil } +// validateListenerTLSTuning validates the optional min/max TLS version, +// cipher suite, and curve preference tuning for one of the inbound HTTPS +// listeners (WebSub or WebSocket). All four fields are optional — leaving +// them empty defers to Go's own crypto/tls defaults — but if set, they must +// name a value tlsconfig recognizes. fieldPrefix is used to qualify the +// returned error (e.g. "server.websub_tls"). +func validateListenerTLSTuning(fieldPrefix, minVersion, maxVersion, cipherSuites, curvePreferences string) error { + if err := tlsconfig.ValidateVersionRange(minVersion, maxVersion); err != nil { + return fmt.Errorf("%s_min_version/%s_max_version: %w", fieldPrefix, fieldPrefix, err) + } + if _, err := tlsconfig.ParseCipherSuites(cipherSuites); err != nil { + return fmt.Errorf("%s_cipher_suites: %w", fieldPrefix, err) + } + if _, err := tlsconfig.ParseCurvePreferences(curvePreferences); err != nil { + return fmt.Errorf("%s_curve_preferences: %w", fieldPrefix, err) + } + return nil +} + func validateKafkaConfig(kafkaCfg KafkaConfig) error { if len(kafkaCfg.Brokers) == 0 { return fmt.Errorf("kafka.brokers must contain at least one broker") @@ -381,6 +584,27 @@ func validateServerPorts(serverCfg ServerConfig) error { return nil } +// validateServerTimeouts rejects a zero/negative read/write/idle timeout or +// max-header-byte ceiling on the managed WebSub/WebSocket HTTP(S) servers — +// the zero value for http.Server leaves these unbounded, which is exactly +// the Slowloris-style exposure this configuration exists to close (see +// go-network-service-hardening.md directive 1). +func validateServerTimeouts(serverCfg ServerConfig) error { + if serverCfg.ReadTimeout <= 0 { + return fmt.Errorf("server.read_timeout must be positive, got %s", serverCfg.ReadTimeout) + } + if serverCfg.WriteTimeout <= 0 { + return fmt.Errorf("server.write_timeout must be positive, got %s", serverCfg.WriteTimeout) + } + if serverCfg.IdleTimeout <= 0 { + return fmt.Errorf("server.idle_timeout must be positive, got %s", serverCfg.IdleTimeout) + } + if serverCfg.MaxHeaderBytes <= 0 { + return fmt.Errorf("server.max_header_bytes must be positive, got %d", serverCfg.MaxHeaderBytes) + } + return nil +} + func validateReadableFile(filePath, fieldName, enabledFieldName string) error { trimmedPath := strings.TrimSpace(filePath) if trimmedPath == "" { diff --git a/event-gateway/gateway-runtime/internal/config/httpclient.go b/event-gateway/gateway-runtime/internal/config/httpclient.go new file mode 100644 index 0000000000..f6ede34e25 --- /dev/null +++ b/event-gateway/gateway-runtime/internal/config/httpclient.go @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "fmt" + + "github.com/wso2/api-platform/httpkit/httpclient" + "github.com/wso2/api-platform/httpkit/netguard" +) + +// BuildHTTPClientConfig translates HTTPClientConfig (sourced from the [http_client] +// section in config.toml) into an httpkit httpclient.Config for the WebSub Verifier and +// Deliverer HTTP clients (see HTTPClientConfig's doc comment for the shared-config +// rationale). The caller is expected to override the returned Timeouts.Overall with its +// own existing per-call-site timeout (Verifier's `timeout` argument / Deliverer's own +// delivery timeout) — this function only fills in the common default. +// +// SSRF guarding is unconditionally enabled here with netguard.PublicOnly() — every +// caller of this shared config dials a tenant/subscriber-supplied CallbackURL, which +// must never be usable to reach an operator's own private/loopback network, so unlike +// gateway-controller's analogous translation there is no Enabled/Preset switch to +// interpret; only the redirect/scheme knobs in HTTPClientSSRFConfig are read from config. +func BuildHTTPClientConfig(hc HTTPClientConfig) (httpclient.Config, error) { + cfg := httpclient.DefaultConfig() + + cfg.Pooling.MaxIdleConns = hc.Pooling.MaxIdleConns + cfg.Pooling.MaxIdleConnsPerHost = hc.Pooling.MaxIdleConnsPerHost + cfg.Pooling.MaxConnsPerHost = hc.Pooling.MaxConnsPerHost + cfg.Pooling.IdleConnTimeout = hc.Pooling.IdleConnTimeout + cfg.Pooling.KeepAlive = hc.Pooling.KeepAlive + cfg.Pooling.DisableKeepAlives = hc.Pooling.DisableKeepAlives + cfg.Pooling.EnableHTTP2 = hc.Pooling.EnableHTTP2 + + cfg.Timeouts.Overall = hc.Timeouts.Overall + cfg.Timeouts.Dial = hc.Timeouts.Dial + cfg.Timeouts.TLSHandshake = hc.Timeouts.TLSHandshake + cfg.Timeouts.ResponseHeader = hc.Timeouts.ResponseHeader + cfg.Timeouts.ExpectContinue = hc.Timeouts.ExpectContinue + // A negative value would disable httpkit's response-size bound entirely + // (see httpclient.TimeoutsConfig.MaxResponseBytes) — never acceptable + // here, since every response body is read from a tenant/subscriber- + // supplied CallbackURL. Reject it rather than forwarding it; 0 still + // selects httpkit's own finite default (10MiB). + if hc.Timeouts.MaxResponseBytes < 0 { + return httpclient.Config{}, fmt.Errorf("http_client.timeouts.max_response_bytes must not be negative (a finite maximum is required for tenant-supplied callback responses)") + } + cfg.Timeouts.MaxResponseBytes = hc.Timeouts.MaxResponseBytes + + cfg.TLS.MinVersion = hc.TLS.MinVersion + cfg.TLS.MaxVersion = hc.TLS.MaxVersion + cfg.TLS.CipherSuites = hc.TLS.CipherSuites + cfg.TLS.CurvePreferences = hc.TLS.CurvePreferences + cfg.TLS.RootCAFile = hc.TLS.RootCAFile + cfg.TLS.ClientCertFile = hc.TLS.ClientCertFile + cfg.TLS.ClientKeyFile = hc.TLS.ClientKeyFile + // InsecureSkipVerify is intentionally not exposed here: unlike gateway-controller + // there is no existing single source-of-truth boolean for it to reuse, and these + // clients dial arbitrary tenant-supplied CallbackURLs, so it stays at its safe + // default (verified) rather than becoming a per-client opt-out. + + switch hc.Proxy.Mode { + case "", "none": + // no proxy — cfg.Proxy stays at its zero value + case "environment": + cfg.Proxy.Mode = "environment" + case "url": + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = hc.Proxy.URL + cfg.Proxy.Username = hc.Proxy.Username + cfg.Proxy.Password = hc.Proxy.Password + cfg.Proxy.NoProxy = hc.Proxy.NoProxy + if hc.Proxy.TLS != (HTTPClientProxyTLSConfig{}) { + cfg.Proxy.ProxyTLS = &httpclient.ProxyTLSConfig{ + RootCAFile: hc.Proxy.TLS.RootCAFile, + ClientCertFile: hc.Proxy.TLS.ClientCertFile, + ClientKeyFile: hc.Proxy.TLS.ClientKeyFile, + InsecureSkipVerify: hc.Proxy.TLS.InsecureSkipVerify, + InsecureSkipVerifyAcknowledged: hc.Proxy.TLS.InsecureSkipVerifyAcknowledged, + } + } + default: + return httpclient.Config{}, fmt.Errorf("http_client.proxy.mode: unrecognized value %q (want \"none\", \"environment\", or \"url\")", hc.Proxy.Mode) + } + + // Always on — see this function's doc comment and HTTPClientConfig's doc comment. + // PublicOnly (not PermitPrivateBlockMetadata) is deliberate: PermitPrivateBlockMetadata + // permits private/loopback/CGNAT addresses, which is appropriate for an + // operator-configured backend but not for a tenant/subscriber-supplied CallbackURL, + // which must never be usable to reach a private network service. + cfg.SSRF.Enabled = true + cfg.SSRF.Policy = netguard.PublicOnly() + cfg.SSRF.Policy.AllowedSchemes = hc.SSRF.AllowedSchemes + cfg.SSRF.MaxRedirects = hc.SSRF.MaxRedirects + + if cfg.Proxy.Mode != "" && cfg.Proxy.Mode != "none" { + switch hc.Proxy.Egress { + case "delegated": + cfg.Proxy.Egress = httpclient.ProxyEgressDelegated + case "manual_connect": + cfg.Proxy.Egress = httpclient.ProxyEgressManualCONNECT + default: + return httpclient.Config{}, fmt.Errorf("http_client.proxy.egress must be \"delegated\" or \"manual_connect\" when http_client.proxy.mode is set (SSRF guarding is always enabled for this client), got %q", hc.Proxy.Egress) + } + } + + return cfg, nil +} diff --git a/event-gateway/gateway-runtime/internal/config/httpclient_test.go b/event-gateway/gateway-runtime/internal/config/httpclient_test.go new file mode 100644 index 0000000000..f659b3ad4b --- /dev/null +++ b/event-gateway/gateway-runtime/internal/config/httpclient_test.go @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "reflect" + "testing" + "time" + + "github.com/wso2/api-platform/httpkit/netguard" +) + +func defaultHTTPClientConfigForTest() HTTPClientConfig { + return DefaultConfig().HTTPClient +} + +func TestBuildHTTPClientConfigDefaultsAlwaysEnableSSRFGuard(t *testing.T) { + cfg, err := BuildHTTPClientConfig(defaultHTTPClientConfigForTest()) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + + if !cfg.SSRF.Enabled { + t.Fatal("expected SSRF.Enabled to always be true, got false") + } + wantPolicy := netguard.PublicOnly() + if !reflect.DeepEqual(cfg.SSRF.Policy, wantPolicy) { + t.Fatalf("expected SSRF.Policy to be PublicOnly, got %+v", cfg.SSRF.Policy) + } + if cfg.Proxy.Mode != "" { + t.Fatalf("expected no proxy mode by default, got %q", cfg.Proxy.Mode) + } +} + +func TestBuildHTTPClientConfigCarriesPoolingTimeoutsAndTLS(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Pooling.MaxIdleConns = 42 + hc.Timeouts.Dial = 5 * time.Second + hc.TLS.MinVersion = "TLS1_3" + hc.SSRF.MaxRedirects = 3 + hc.SSRF.AllowedSchemes = []string{"https", "http"} + + cfg, err := BuildHTTPClientConfig(hc) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + + if cfg.Pooling.MaxIdleConns != 42 { + t.Errorf("expected Pooling.MaxIdleConns=42, got %d", cfg.Pooling.MaxIdleConns) + } + if cfg.Timeouts.Dial != 5*time.Second { + t.Errorf("expected Timeouts.Dial=5s, got %v", cfg.Timeouts.Dial) + } + if cfg.TLS.MinVersion != "TLS1_3" { + t.Errorf("expected TLS.MinVersion=TLS1_3, got %q", cfg.TLS.MinVersion) + } + if cfg.SSRF.MaxRedirects != 3 { + t.Errorf("expected SSRF.MaxRedirects=3, got %d", cfg.SSRF.MaxRedirects) + } + if len(cfg.SSRF.Policy.AllowedSchemes) != 2 || cfg.SSRF.Policy.AllowedSchemes[0] != "https" { + t.Errorf("expected SSRF.Policy.AllowedSchemes=[https http], got %v", cfg.SSRF.Policy.AllowedSchemes) + } +} + +func TestBuildHTTPClientConfigRejectsUnknownProxyMode(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Proxy.Mode = "socks5" + + if _, err := BuildHTTPClientConfig(hc); err == nil { + t.Fatal("expected error for unrecognized proxy.mode, got nil") + } +} + +func TestBuildHTTPClientConfigRequiresEgressWhenProxyConfigured(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Proxy.Mode = "url" + hc.Proxy.URL = "https://proxy.example.com:3128" + hc.Proxy.Egress = "" + + if _, err := BuildHTTPClientConfig(hc); err == nil { + t.Fatal("expected error when proxy is configured without an explicit egress policy, got nil") + } + + hc.Proxy.Egress = "delegated" + cfg, err := BuildHTTPClientConfig(hc) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error with valid egress: %v", err) + } + if cfg.Proxy.Mode != "url" || cfg.Proxy.URL != hc.Proxy.URL { + t.Errorf("expected proxy url mode carried through, got %+v", cfg.Proxy) + } +} + +func TestBuildHTTPClientConfigEnvironmentProxyMode(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Proxy.Mode = "environment" + hc.Proxy.Egress = "manual_connect" + + cfg, err := BuildHTTPClientConfig(hc) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + if cfg.Proxy.Mode != "environment" { + t.Errorf("expected proxy mode environment, got %q", cfg.Proxy.Mode) + } +} + +func TestBuildHTTPClientConfigRejectsNegativeMaxResponseBytes(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Timeouts.MaxResponseBytes = -1 + + if _, err := BuildHTTPClientConfig(hc); err == nil { + t.Fatal("expected error for negative http_client.timeouts.max_response_bytes, got nil") + } +} + +func TestBuildHTTPClientConfigProxyTLSRequiresSeparateAcknowledgement(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Proxy.Mode = "url" + hc.Proxy.URL = "https://proxy.example.com:3128" + hc.Proxy.Egress = "delegated" + hc.Proxy.TLS.InsecureSkipVerify = true + // InsecureSkipVerifyAcknowledged intentionally left unset. + + cfg, err := BuildHTTPClientConfig(hc) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + if cfg.Proxy.ProxyTLS == nil { + t.Fatal("expected ProxyTLS to be set") + } + if !cfg.Proxy.ProxyTLS.InsecureSkipVerify { + t.Fatal("expected InsecureSkipVerify to be carried through") + } + if cfg.Proxy.ProxyTLS.InsecureSkipVerifyAcknowledged { + t.Fatal("expected InsecureSkipVerifyAcknowledged to stay false when not explicitly set, independent of InsecureSkipVerify") + } + + hc.Proxy.TLS.InsecureSkipVerifyAcknowledged = true + cfg, err = BuildHTTPClientConfig(hc) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + if !cfg.Proxy.ProxyTLS.InsecureSkipVerifyAcknowledged { + t.Fatal("expected InsecureSkipVerifyAcknowledged to be carried through once explicitly set") + } +} diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go index 1cbda9e9ef..4d6807576c 100644 --- a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go @@ -25,6 +25,7 @@ import ( "time" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/binding" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/config" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/connectors" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/subscription" ) @@ -40,6 +41,10 @@ type Options struct { DeliveryConcurrency int RuntimeID string ConsumerGroupPrefix string + // HTTPClient configures the shared outbound HTTP client used by both the + // Verifier (subscribe/unsubscribe intent verification) and the Deliverer + // (message delivery) — see config.HTTPClientConfig's doc comment. + HTTPClient config.HTTPClientConfig } // WebSubReceiver is a multi-channel WebSub receiver. @@ -75,12 +80,16 @@ func NewReceiver(cfg connectors.ReceiverConfig, opts Options) (connectors.Receiv topics.Register(cfg.Channel.PublicTopic) } - deliverer := NewDeliverer(DeliveryConfig{ + deliverer, err := NewDeliverer(DeliveryConfig{ MaxRetries: opts.DeliveryMaxRetries, InitialDelayMs: opts.DeliveryInitialDelayMs, MaxDelayMs: opts.DeliveryMaxDelayMs, Concurrency: opts.DeliveryConcurrency, + HTTPClient: opts.HTTPClient, }) + if err != nil { + return nil, fmt.Errorf("failed to create deliverer: %w", err) + } // Create consumer manager for per-callback consumers. consumerMgr := NewConsumerManager( @@ -100,11 +109,15 @@ func NewReceiver(cfg connectors.ReceiverConfig, opts Options) (connectors.Receiv verificationTimeout := time.Duration(opts.VerificationTimeoutSeconds) * time.Second // Create HubHandler for subscribe/unsubscribe on {context}/{version}/hub. - hubHandler := NewHubHandler( + hubHandler, err := NewHubHandler( topics, store, verificationTimeout, opts.DefaultLeaseSeconds, cfg.Processor, cfg.BrokerDriver, cfg.Channel.Name, cfg.Channel.Channels, consumerMgr, syncProducer, + opts.HTTPClient, ) + if err != nil { + return nil, fmt.Errorf("failed to create hub handler: %w", err) + } // Create WebhookReceiverHandler for ingress on {context}/{version}/webhook-receiver. webhookHandler := NewWebhookReceiverHandler( diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/delivery.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/delivery.go index fbda44619b..3ac85cf4e5 100644 --- a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/delivery.go +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/delivery.go @@ -30,7 +30,9 @@ import ( "strings" "time" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/config" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/connectors" + "github.com/wso2/api-platform/httpkit/httpclient" ) // DeliveryConfig holds configuration for the delivery engine. @@ -39,6 +41,11 @@ type DeliveryConfig struct { InitialDelayMs int MaxDelayMs int Concurrency int + // HTTPClient sources pooling/TLS/proxy/SSRF-redirect knobs from config.toml's + // [http_client] section (shared with the Verifier). The delivery timeout below + // remains this call site's own existing value and always overrides + // HTTPClient.Timeouts.Overall — see config.HTTPClientConfig's doc comment. + HTTPClient config.HTTPClientConfig } // Deliverer delivers events to a single subscriber callback URL. @@ -47,12 +54,31 @@ type Deliverer struct { client *http.Client } -// NewDeliverer creates a new Deliverer. -func NewDeliverer(config DeliveryConfig) *Deliverer { - return &Deliverer{ - config: config, - client: &http.Client{Timeout: 30 * time.Second}, +// NewDeliverer creates a new Deliverer. The delivery HTTP client is built +// with the shared httpkit SSRF dial-guard enabled (see ssrf-prevention.md): +// the subscriber CallbackURL is tenant-supplied, so private/loopback +// backends must remain reachable (in-cluster subscribers are the common +// case) while link-local/metadata/unspecified/multicast destinations are +// refused at dial time. Pooling/TLS/proxy knobs come from +// deliveryConfig.HTTPClient (config.toml's [http_client] section, shared +// with the Verifier); the 30s delivery timeout below is this call site's +// own existing value and always overrides HTTPClient.Timeouts.Overall. +func NewDeliverer(deliveryConfig DeliveryConfig) (*Deliverer, error) { + cfg, err := config.BuildHTTPClientConfig(deliveryConfig.HTTPClient) + if err != nil { + return nil, fmt.Errorf("failed to build delivery HTTP client config: %w", err) } + cfg.Timeouts.Overall = 30 * time.Second + + client, err := httpclient.New(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build delivery HTTP client: %w", err) + } + + return &Deliverer{ + config: deliveryConfig, + client: client, + }, nil } // Deliver delivers a message to a single callback URL with retry and HMAC. diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/handler.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/handler.go index 87a210a40f..7eeb25d0c2 100644 --- a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/handler.go +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/handler.go @@ -27,6 +27,7 @@ import ( "strconv" "time" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/config" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/connectors" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/subscription" ) @@ -58,11 +59,17 @@ func NewHubHandler( channels map[string]string, consumerMgr *ConsumerManager, syncProducer *subscription.SyncProducer, -) *HubHandler { + httpClientConfig config.HTTPClientConfig, +) (*HubHandler, error) { + verifier, err := NewVerifier(store, verificationTimeout, httpClientConfig) + if err != nil { + return nil, fmt.Errorf("failed to create verifier: %w", err) + } + return &HubHandler{ topics: topics, store: store, - verifier: NewVerifier(store, verificationTimeout), + verifier: verifier, processor: processor, brokerDriver: brokerDriver, bindingName: bindingName, @@ -70,7 +77,7 @@ func NewHubHandler( consumerMgr: consumerMgr, syncProducer: syncProducer, defaultLease: defaultLease, - } + }, nil } // ServeHTTP dispatches on hub.mode for form-encoded subscribe/unsubscribe requests. diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/verification.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/verification.go index a5cccc4e19..d4a0394c32 100644 --- a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/verification.go +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/verification.go @@ -30,7 +30,9 @@ import ( "strconv" "time" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/config" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/subscription" + "github.com/wso2/api-platform/httpkit/httpclient" ) // Verifier performs W3C WebSub §5.3 intent verification for subscribe/unsubscribe. @@ -40,15 +42,32 @@ type Verifier struct { client *http.Client } -// NewVerifier creates a new Verifier. -func NewVerifier(store subscription.SubscriptionStore, timeout time.Duration) *Verifier { +// NewVerifier creates a new Verifier. The verification HTTP client is built +// with the shared httpkit SSRF dial-guard enabled (see ssrf-prevention.md): +// the subscriber CallbackURL being verified is tenant-supplied, so +// private/loopback backends must remain reachable (in-cluster subscribers +// are the common case) while link-local/metadata/unspecified/multicast +// destinations are refused at dial time. Pooling/TLS/proxy knobs come from +// hc (config.toml's [http_client] section, shared with the Deliverer); +// timeout is this call site's own existing parameter and always overrides +// hc.Timeouts.Overall — see config.HTTPClientConfig's doc comment. +func NewVerifier(store subscription.SubscriptionStore, timeout time.Duration, hc config.HTTPClientConfig) (*Verifier, error) { + cfg, err := config.BuildHTTPClientConfig(hc) + if err != nil { + return nil, fmt.Errorf("failed to build verification HTTP client config: %w", err) + } + cfg.Timeouts.Overall = timeout + + client, err := httpclient.New(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build verification HTTP client: %w", err) + } + return &Verifier{ store: store, timeout: timeout, - client: &http.Client{ - Timeout: timeout, - }, - } + client: client, + }, nil } // VerifySubscribe performs intent verification for a subscribe request per W3C WebSub §5.3. diff --git a/event-gateway/gateway-runtime/internal/runtime/runtime.go b/event-gateway/gateway-runtime/internal/runtime/runtime.go index 58858a0a68..bbeb45d506 100644 --- a/event-gateway/gateway-runtime/internal/runtime/runtime.go +++ b/event-gateway/gateway-runtime/internal/runtime/runtime.go @@ -20,6 +20,7 @@ package runtime import ( "context" + "crypto/tls" "fmt" "log/slog" "net/http" @@ -36,6 +37,7 @@ import ( "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/hub" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/systempolicies" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/pkg/engine" + "github.com/wso2/api-platform/httpkit/tlsconfig" ) var ( @@ -403,14 +405,14 @@ func (r *Runtime) LoadChannels(channelsPath string) error { // Create shared HTTP servers. if hasWS { - wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, wsMux, "", "") + wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, wsMux, "", "", serverTLSOptions{}) if err != nil { return fmt.Errorf("failed to create WebSocket server: %w", err) } r.servers = append(r.servers, wsServer) // Create WSS server if TLS is enabled if r.cfg.Server.WebSocketTLSEnabled { - wssServer, err := r.newManagedServer("WebSocket-HTTPS", r.cfg.Server.WebSocketHTTPSPort, wsMux, r.cfg.Server.WebSocketTLSCertFile, r.cfg.Server.WebSocketTLSKeyFile) + wssServer, err := r.newManagedServer("WebSocket-HTTPS", r.cfg.Server.WebSocketHTTPSPort, wsMux, r.cfg.Server.WebSocketTLSCertFile, r.cfg.Server.WebSocketTLSKeyFile, webSocketServerTLSOptions(r.cfg.Server)) if err != nil { return fmt.Errorf("failed to create WebSocket HTTPS server: %w", err) } @@ -419,14 +421,14 @@ func (r *Runtime) LoadChannels(channelsPath string) error { } if hasWebSub && r.cfg.Server.WebSubEnabled { // Create HTTP server - websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, websubMux, "", "") + websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, websubMux, "", "", serverTLSOptions{}) if err != nil { return fmt.Errorf("failed to create WebSub HTTP server: %w", err) } r.servers = append(r.servers, websubHTTPServer) // Create HTTPS server if TLS is enabled if r.cfg.Server.WebSubTLSEnabled { - websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, websubMux, r.cfg.Server.WebSubTLSCertFile, r.cfg.Server.WebSubTLSKeyFile) + websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, websubMux, r.cfg.Server.WebSubTLSCertFile, r.cfg.Server.WebSubTLSKeyFile, webSubServerTLSOptions(r.cfg.Server)) if err != nil { return fmt.Errorf("failed to create WebSub HTTPS server: %w", err) } @@ -454,7 +456,7 @@ func (r *Runtime) Run(ctx context.Context) error { if r.cfg.ControlPlane.Enabled { // Create WebSocket server for dynamic WebBrokerApi bindings slog.Info("Creating WebSocket server for dynamic WebBrokerApi bindings", "port", r.cfg.Server.WebSocketPort) - wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, r.wsMux, "", "") + wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, r.wsMux, "", "", serverTLSOptions{}) if err != nil { r.mu.Unlock() return fmt.Errorf("failed to create WebSocket server: %w", err) @@ -467,7 +469,7 @@ func (r *Runtime) Run(ctx context.Context) error { // Create WSS server if TLS is enabled if r.cfg.Server.WebSocketTLSEnabled { slog.Info("Creating WebSocket HTTPS server for dynamic WebBrokerApi bindings", "port", r.cfg.Server.WebSocketHTTPSPort) - wssServer, err := r.newManagedServer("WebSocket-HTTPS", r.cfg.Server.WebSocketHTTPSPort, r.wsMux, r.cfg.Server.WebSocketTLSCertFile, r.cfg.Server.WebSocketTLSKeyFile) + wssServer, err := r.newManagedServer("WebSocket-HTTPS", r.cfg.Server.WebSocketHTTPSPort, r.wsMux, r.cfg.Server.WebSocketTLSCertFile, r.cfg.Server.WebSocketTLSKeyFile, webSocketServerTLSOptions(r.cfg.Server)) if err != nil { r.mu.Unlock() return fmt.Errorf("failed to create WebSocket HTTPS server: %w", err) @@ -481,7 +483,7 @@ func (r *Runtime) Run(ctx context.Context) error { // Create WebSub servers for dynamic WebSubApi bindings if r.cfg.Server.WebSubEnabled { slog.Info("Creating WebSub HTTP server for dynamic WebSubApi bindings", "port", r.cfg.Server.WebSubHTTPPort) - websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, r.websubMux, "", "") + websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, r.websubMux, "", "", serverTLSOptions{}) if err != nil { r.mu.Unlock() return fmt.Errorf("failed to create WebSub HTTP server: %w", err) @@ -494,7 +496,7 @@ func (r *Runtime) Run(ctx context.Context) error { // Create HTTPS server if TLS is enabled if r.cfg.Server.WebSubTLSEnabled { slog.Info("Creating WebSub HTTPS server for dynamic WebSubApi bindings", "port", r.cfg.Server.WebSubHTTPSPort) - websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, r.websubMux, r.cfg.Server.WebSubTLSCertFile, r.cfg.Server.WebSubTLSKeyFile) + websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, r.websubMux, r.cfg.Server.WebSubTLSCertFile, r.cfg.Server.WebSubTLSKeyFile, webSubServerTLSOptions(r.cfg.Server)) if err != nil { r.mu.Unlock() return fmt.Errorf("failed to create WebSub HTTPS server: %w", err) @@ -584,12 +586,49 @@ func (r *Runtime) Run(ctx context.Context) error { return nil } -func (r *Runtime) newManagedServer(name string, port int, handler http.Handler, certFile, keyFile string) (*managedServer, error) { +// serverTLSOptions carries the optional cipher suite, ECDH/curve preference, +// and min/max TLS version tuning for one inbound HTTPS listener (WebSub or +// WebSocket). The zero value means "use Go's crypto/tls defaults for +// everything" — every field is independently optional. +type serverTLSOptions struct { + MinVersion string + MaxVersion string + CipherSuites string + CurvePreferences string +} + +// webSubServerTLSOptions extracts the WebSub-HTTPS listener's TLS tuning +// from ServerConfig, validated up front by config.validate. +func webSubServerTLSOptions(cfg config.ServerConfig) serverTLSOptions { + return serverTLSOptions{ + MinVersion: cfg.WebSubTLSMinVersion, + MaxVersion: cfg.WebSubTLSMaxVersion, + CipherSuites: cfg.WebSubTLSCipherSuites, + CurvePreferences: cfg.WebSubTLSCurvePreferences, + } +} + +// webSocketServerTLSOptions extracts the WebSocket-HTTPS listener's TLS +// tuning from ServerConfig, validated up front by config.validate. +func webSocketServerTLSOptions(cfg config.ServerConfig) serverTLSOptions { + return serverTLSOptions{ + MinVersion: cfg.WebSocketTLSMinVersion, + MaxVersion: cfg.WebSocketTLSMaxVersion, + CipherSuites: cfg.WebSocketTLSCipherSuites, + CurvePreferences: cfg.WebSocketTLSCurvePreferences, + } +} + +func (r *Runtime) newManagedServer(name string, port int, handler http.Handler, certFile, keyFile string, tlsOpts serverTLSOptions) (*managedServer, error) { server := &managedServer{ name: name, server: &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: handler, + Addr: fmt.Sprintf(":%d", port), + Handler: handler, + ReadTimeout: r.cfg.Server.ReadTimeout, + WriteTimeout: r.cfg.Server.WriteTimeout, + IdleTimeout: r.cfg.Server.IdleTimeout, + MaxHeaderBytes: r.cfg.Server.MaxHeaderBytes, }, } @@ -600,14 +639,68 @@ func (r *Runtime) newManagedServer(name string, port int, handler http.Handler, if err := ensureReadableTLSAsset(keyFile, name+" TLS key file"); err != nil { return nil, fmt.Errorf("invalid TLS configuration for %s server: %w", name, err) } + + tlsConfig, err := buildListenerTLSConfig(tlsOpts) + if err != nil { + return nil, fmt.Errorf("invalid TLS configuration for %s server: %w", name, err) + } + server.tls = true server.certFile = certFile server.keyFile = keyFile + server.server.TLSConfig = tlsConfig } return server, nil } +// buildListenerTLSConfig builds the *tls.Config carrying the optional +// cipher/curve/version tuning for an inbound HTTPS listener. The certificate +// itself is intentionally left unset here: http.Server.ListenAndServeTLS +// loads it from the cert/key file paths at Serve time (see runServer) and +// merges it into whatever TLSConfig is already set, leaving +// MinVersion/MaxVersion/CipherSuites/CurvePreferences untouched. Config +// validation (config.validate, via validateListenerTLSTuning) already +// parses these same fields at startup, so an error here would only surface +// if that validation were ever skipped — still handled explicitly rather +// than ignored. +func buildListenerTLSConfig(opts serverTLSOptions) (*tls.Config, error) { + if err := tlsconfig.ValidateVersionRange(opts.MinVersion, opts.MaxVersion); err != nil { + return nil, err + } + cfg := &tls.Config{} + if opts.MinVersion != "" { + if v, ok := tlsconfig.ParseVersion(opts.MinVersion); ok { + cfg.MinVersion = v + } + } + if opts.MaxVersion != "" { + if v, ok := tlsconfig.ParseVersion(opts.MaxVersion); ok { + cfg.MaxVersion = v + } + } + ciphers, err := tlsconfig.ParseCipherSuites(opts.CipherSuites) + if err != nil { + return nil, err + } + cfg.CipherSuites = ciphers + curves, err := tlsconfig.ParseCurvePreferences(opts.CurvePreferences) + if err != nil { + return nil, err + } + if len(curves) == 0 { + // No explicit preference configured — default to the hybrid PQC group + // first, with classical fallbacks after, rather than falling through + // to Go's own implicit (classical-only) curve list. A peer that + // doesn't yet support X25519MLKEM768 still completes the handshake + // via one of the later entries (post-quantum-cryptography.md + // directive 3). + curves = []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256, tls.CurveP384} + } + cfg.CurvePreferences = curves + return cfg, nil +} + func ensureReadableTLSAsset(filePath, fieldName string) error { info, err := os.Stat(filePath) if err != nil { diff --git a/event-gateway/gateway-runtime/internal/runtime/runtime_test.go b/event-gateway/gateway-runtime/internal/runtime/runtime_test.go index 837584e973..d50d849e9b 100644 --- a/event-gateway/gateway-runtime/internal/runtime/runtime_test.go +++ b/event-gateway/gateway-runtime/internal/runtime/runtime_test.go @@ -20,10 +20,12 @@ package runtime import ( "context" + "crypto/tls" "errors" "net/http" "os" "path/filepath" + "reflect" "strings" "sync" "testing" @@ -224,7 +226,7 @@ func TestNewManagedServerRejectsMissingTLSFiles(t *testing.T) { }, } - _, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), rt.cfg.Server.WebSubTLSCertFile, rt.cfg.Server.WebSubTLSKeyFile) + _, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), rt.cfg.Server.WebSubTLSCertFile, rt.cfg.Server.WebSubTLSKeyFile, serverTLSOptions{}) if err == nil { t.Fatal("expected newManagedServer to fail when TLS files are missing") } @@ -247,14 +249,17 @@ func TestNewManagedServerAcceptsReadableTLSFiles(t *testing.T) { rt := &Runtime{ cfg: &config.Config{ Server: config.ServerConfig{ - WebSubTLSEnabled: true, - WebSubTLSCertFile: certPath, - WebSubTLSKeyFile: keyPath, + WebSubTLSEnabled: true, + WebSubTLSCertFile: certPath, + WebSubTLSKeyFile: keyPath, + WebSubTLSMinVersion: "TLS1_2", + WebSubTLSMaxVersion: "TLS1_3", + WebSubTLSCurvePreferences: "X25519MLKEM768,X25519,P-256", }, }, } - server, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), certPath, keyPath) + server, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), certPath, keyPath, webSubServerTLSOptions(rt.cfg.Server)) if err != nil { t.Fatalf("expected newManagedServer to succeed, got %v", err) } @@ -267,6 +272,94 @@ func TestNewManagedServerAcceptsReadableTLSFiles(t *testing.T) { if server.keyFile != keyPath { t.Fatalf("expected key path %q, got %q", keyPath, server.keyFile) } + if server.server.TLSConfig == nil { + t.Fatal("expected TLSConfig to be set on the managed server") + } + if server.server.TLSConfig.MinVersion != tls.VersionTLS12 { + t.Fatalf("expected MinVersion TLS1.2, got %x", server.server.TLSConfig.MinVersion) + } + if server.server.TLSConfig.MaxVersion != tls.VersionTLS13 { + t.Fatalf("expected MaxVersion TLS1.3, got %x", server.server.TLSConfig.MaxVersion) + } + wantCurves := []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256} + if !reflect.DeepEqual(server.server.TLSConfig.CurvePreferences, wantCurves) { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, server.server.TLSConfig.CurvePreferences) + } +} + +func TestNewManagedServerRejectsInvalidTLSTuning(t *testing.T) { + tempDir := t.TempDir() + certPath := filepath.Join(tempDir, "tls.crt") + keyPath := filepath.Join(tempDir, "tls.key") + if err := os.WriteFile(certPath, []byte("cert"), 0o644); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + + rt := &Runtime{cfg: &config.Config{}} + + _, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), certPath, keyPath, serverTLSOptions{CurvePreferences: "not-a-curve"}) + if err == nil { + t.Fatal("expected newManagedServer to fail on an invalid curve name") + } + if !strings.Contains(err.Error(), "invalid TLS configuration for WebSub-HTTPS server") { + t.Fatalf("expected wrapped TLS configuration error, got %q", err.Error()) + } +} + +func TestNewManagedServerDefaultsToHybridPQCCurvesWhenUnset(t *testing.T) { + tempDir := t.TempDir() + certPath := filepath.Join(tempDir, "tls.crt") + keyPath := filepath.Join(tempDir, "tls.key") + if err := os.WriteFile(certPath, []byte("cert"), 0o644); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + + rt := &Runtime{cfg: &config.Config{}} + + server, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), certPath, keyPath, serverTLSOptions{}) + if err != nil { + t.Fatalf("expected newManagedServer to succeed, got %v", err) + } + wantCurves := []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256, tls.CurveP384} + if !reflect.DeepEqual(server.server.TLSConfig.CurvePreferences, wantCurves) { + t.Fatalf("expected default hybrid PQC curve preferences %v, got %v", wantCurves, server.server.TLSConfig.CurvePreferences) + } +} + +func TestNewManagedServerAppliesConfiguredTimeouts(t *testing.T) { + rt := &Runtime{ + cfg: &config.Config{ + Server: config.ServerConfig{ + ReadTimeout: 11 * time.Second, + WriteTimeout: 22 * time.Second, + IdleTimeout: 33 * time.Second, + MaxHeaderBytes: 4096, + }, + }, + } + + server, err := rt.newManagedServer("WebSub-HTTP", 8080, http.NewServeMux(), "", "", serverTLSOptions{}) + if err != nil { + t.Fatalf("expected newManagedServer to succeed, got %v", err) + } + if server.server.ReadTimeout != 11*time.Second { + t.Errorf("expected ReadTimeout=11s, got %v", server.server.ReadTimeout) + } + if server.server.WriteTimeout != 22*time.Second { + t.Errorf("expected WriteTimeout=22s, got %v", server.server.WriteTimeout) + } + if server.server.IdleTimeout != 33*time.Second { + t.Errorf("expected IdleTimeout=33s, got %v", server.server.IdleTimeout) + } + if server.server.MaxHeaderBytes != 4096 { + t.Errorf("expected MaxHeaderBytes=4096, got %d", server.server.MaxHeaderBytes) + } } func TestNewManagedServerWebSocketRejectsMissingTLSFiles(t *testing.T) { @@ -280,7 +373,7 @@ func TestNewManagedServerWebSocketRejectsMissingTLSFiles(t *testing.T) { }, } - _, err := rt.newManagedServer("WebSocket-HTTPS", 8444, http.NewServeMux(), rt.cfg.Server.WebSocketTLSCertFile, rt.cfg.Server.WebSocketTLSKeyFile) + _, err := rt.newManagedServer("WebSocket-HTTPS", 8444, http.NewServeMux(), rt.cfg.Server.WebSocketTLSCertFile, rt.cfg.Server.WebSocketTLSKeyFile, serverTLSOptions{}) if err == nil { t.Fatal("expected newManagedServer to fail when TLS files are missing") } @@ -303,14 +396,15 @@ func TestNewManagedServerWebSocketAcceptsReadableTLSFiles(t *testing.T) { rt := &Runtime{ cfg: &config.Config{ Server: config.ServerConfig{ - WebSocketTLSEnabled: true, - WebSocketTLSCertFile: certPath, - WebSocketTLSKeyFile: keyPath, + WebSocketTLSEnabled: true, + WebSocketTLSCertFile: certPath, + WebSocketTLSKeyFile: keyPath, + WebSocketTLSCipherSuites: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", }, }, } - server, err := rt.newManagedServer("WebSocket-HTTPS", 8444, http.NewServeMux(), certPath, keyPath) + server, err := rt.newManagedServer("WebSocket-HTTPS", 8444, http.NewServeMux(), certPath, keyPath, webSocketServerTLSOptions(rt.cfg.Server)) if err != nil { t.Fatalf("expected newManagedServer to succeed, got %v", err) } @@ -323,6 +417,13 @@ func TestNewManagedServerWebSocketAcceptsReadableTLSFiles(t *testing.T) { if server.keyFile != keyPath { t.Fatalf("expected key path %q, got %q", keyPath, server.keyFile) } + if server.server.TLSConfig == nil { + t.Fatal("expected TLSConfig to be set on the managed server") + } + wantCiphers := []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256} + if !reflect.DeepEqual(server.server.TLSConfig.CipherSuites, wantCiphers) { + t.Fatalf("expected cipher suites %v, got %v", wantCiphers, server.server.TLSConfig.CipherSuites) + } } func TestAddWebBrokerApiBinding_RedeployDoesNotPanic(t *testing.T) { @@ -435,8 +536,8 @@ func (testBrokerDriver) EnsureTopics(_ context.Context, _ []string, _ map[string return nil } func (testBrokerDriver) EnsureCompactedTopic(_ context.Context, _ string) error { return nil } -func (testBrokerDriver) DeleteTopics(_ context.Context, _ []string) error { return nil } -func (testBrokerDriver) Close() error { return nil } +func (testBrokerDriver) DeleteTopics(_ context.Context, _ []string) error { return nil } +func (testBrokerDriver) Close() error { return nil } func newTestNoopPolicy(policy.PolicyMetadata, map[string]interface{}) (policy.Policy, error) { return testNoopPolicy{}, nil diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 1baaf9f679..162b3d2cfc 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -43,6 +43,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/version" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" + "github.com/wso2/api-platform/httpkit/httpclient" gohttpkit "github.com/wso2/api-platform/httpkit/middleware" ) @@ -389,7 +390,16 @@ func main() { policyEngineConnected := make(chan struct{}) // Start xDS gRPC server with SDS support - xdsServer := xds.NewServer(snapshotManager, sdsSecretManager, cfg.Controller.Server.XDSPort, log, routerConnected) + var xdsServerOpts []xds.ServerOption + if cfg.Controller.Server.XDSTLS.Enabled { + xdsTLSConfig, err := config.BuildXDSServerTLSConfig(cfg.Controller.Server.XDSTLS) + if err != nil { + log.Error("invalid server.xds_tls config, refusing to start main xDS server in plaintext", slog.Any("error", err)) + os.Exit(1) + } + xdsServerOpts = append(xdsServerOpts, xds.WithMTLS(xdsTLSConfig, cfg.Controller.Server.XDSTLS.AllowedClientIdentities)) + } + xdsServer := xds.NewServer(snapshotManager, sdsSecretManager, cfg.Controller.Server.XDSPort, log, routerConnected, xdsServerOpts...) go func() { if err := xdsServer.Start(); err != nil { log.Error("xDS server failed", slog.Any("error", err)) @@ -490,10 +500,12 @@ func main() { policyxds.WithOnFirstConnect(policyEngineConnected), } if cfg.Controller.PolicyServer.TLS.Enabled { - serverOpts = append(serverOpts, policyxds.WithTLS( - cfg.Controller.PolicyServer.TLS.CertFile, - cfg.Controller.PolicyServer.TLS.KeyFile, - )) + policyXDSTLSConfig, err := config.BuildXDSServerTLSConfig(cfg.Controller.PolicyServer.TLS) + if err != nil { + log.Error("invalid policy_server.tls config, refusing to start policy xDS server in plaintext", slog.Any("error", err)) + os.Exit(1) + } + serverOpts = append(serverOpts, policyxds.WithMTLS(policyXDSTLSConfig, cfg.Controller.PolicyServer.TLS.AllowedClientIdentities)) } policyXDSServer := policyxds.NewServer(policySnapshotManager, apiKeySnapshotManager, lazyResourceSnapshotManager, subscriptionSnapshotManager, nil, cfg.Controller.PolicyServer.Port, log, serverOpts...) go func() { @@ -519,7 +531,23 @@ func main() { policyValidator := config.NewPolicyValidator(policyDefinitions) validator.SetPolicyValidator(policyValidator) - apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService) + // Build the single shared outbound *http.Client used by every control-plane / + // platform-API / on-prem-APIM call this process makes. Built once, here, and injected + // into every constructor below instead of each one building (or caching) its own — + // real per-operation timeout budgets are enforced via context.WithTimeout at each call + // site, not by this client's own Timeout, which is only a generous safety-net backstop. + sharedHTTPClientCfg, err := config.BuildHTTPClientConfig(cfg.Controller.HTTPClient, cfg.Controller.ControlPlane.InsecureSkipVerify) + if err != nil { + log.Error("Invalid controller.http_client configuration", slog.Any("error", err)) + os.Exit(1) + } + sharedHTTPClient, err := httpclient.New(sharedHTTPClientCfg) + if err != nil { + log.Error("Failed to build shared outbound HTTP client", slog.Any("error", err)) + os.Exit(1) + } + + apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService, sharedHTTPClient) mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService) llmSvc := utils.NewLLMDeploymentService(configStore, db, snapshotManager, lazyResourceXDSManager, templateDefinitions, apiSvc, &cfg.Router, policyVersionResolver, policyValidator) @@ -542,6 +570,7 @@ func main() { secretsService, webhooksecret.GetStoreInstance(), nil, + sharedHTTPClient, ) if err := cpClient.Start(); err != nil { log.Error("Failed to start control plane client", slog.Any("error", err)) @@ -560,7 +589,7 @@ func main() { configStore, db, snapshotManager, policyManager, apiSvc, apiKeyXDSManager, cpClient, &cfg.Router, cfg, - &http.Client{Timeout: 10 * time.Second}, config.NewParser(), validator, log, + sharedHTTPClient, config.NewParser(), validator, log, eventHubInstance, secretsService, ) igw := immutable.NewImmutableGW(cfg.ImmutableGateway, restAPIService, llmSvc, mcpSvc) @@ -612,7 +641,7 @@ func main() { log.Info("EventListener started for multi-replica sync") // Initialize API server with the configured validator and API key manager - apiServer := handlers.NewAPIServer( + apiServer, err := handlers.NewAPIServer( configStore, db, snapshotManager, @@ -629,7 +658,12 @@ func main() { subscriptionSnapshotManager, secretsService, restAPIService, + sharedHTTPClient, ) + if err != nil { + log.Error("Failed to create API server", slog.Any("error", err)) + os.Exit(1) + } // Load immutable gateway artifacts from the filesystem (no-op when immutable mode is disabled). if err := igw.LoadArtifacts(log); err != nil { @@ -738,23 +772,54 @@ func main() { metrics.StartMemoryMetricsUpdater(metricsCtx, 15*time.Second) } - // Start REST API server - log.Info("Starting REST API server", slog.Int("port", cfg.Controller.Server.APIPort)) - - // Setup graceful shutdown - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.Controller.Server.APIPort), - Handler: handler, - ReadHeaderTimeout: 30 * time.Second, - } - - // Start server in a goroutine - go func() { - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Error("Failed to start REST API server", slog.Any("error", err)) + // Start REST API server. When server.tls is enabled, only the TLS listener + // is started -- plaintext management traffic (which carries credentials + // and API-key material) must not keep flowing once an operator has + // explicitly opted into TLS for this API. A misconfigured/missing + // certificate at that point fails startup rather than silently falling + // back to plaintext. + var srv, tlsSrv *http.Server + if cfg.Controller.Server.TLS.Enabled { + tlsConfig, err := buildRESTAPITLSConfig(&cfg.Controller.Server.TLS) + if err != nil { + log.Error("invalid server.tls config", slog.Any("error", err)) os.Exit(1) } - }() + tlsSrv = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Controller.Server.TLS.Port), + Handler: handler, + ReadTimeout: cfg.Controller.Server.ReadTimeout, + ReadHeaderTimeout: cfg.Controller.Server.ReadHeaderTimeout, + WriteTimeout: cfg.Controller.Server.WriteTimeout, + IdleTimeout: cfg.Controller.Server.IdleTimeout, + MaxHeaderBytes: cfg.Controller.Server.MaxHeaderBytes, + TLSConfig: tlsConfig, + } + go func() { + log.Info("Starting REST API TLS server", slog.Int("port", cfg.Controller.Server.TLS.Port)) + if err := tlsSrv.ListenAndServeTLS(cfg.Controller.Server.TLS.CertPath, cfg.Controller.Server.TLS.KeyPath); err != nil && err != http.ErrServerClosed { + log.Error("REST API TLS server error", slog.Any("error", err)) + os.Exit(1) + } + }() + } else { + log.Info("Starting REST API server", slog.Int("port", cfg.Controller.Server.APIPort)) + srv = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Controller.Server.APIPort), + Handler: handler, + ReadTimeout: cfg.Controller.Server.ReadTimeout, + ReadHeaderTimeout: cfg.Controller.Server.ReadHeaderTimeout, + WriteTimeout: cfg.Controller.Server.WriteTimeout, + IdleTimeout: cfg.Controller.Server.IdleTimeout, + MaxHeaderBytes: cfg.Controller.Server.MaxHeaderBytes, + } + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("Failed to start REST API server", slog.Any("error", err)) + os.Exit(1) + } + }() + } log.Info("Gateway Controller started successfully") @@ -803,8 +868,16 @@ func main() { // Stop control plane client cpClient.Stop() - if err := srv.Shutdown(ctx); err != nil { - log.Error("Server forced to shutdown", slog.Any("error", err)) + if srv != nil { + if err := srv.Shutdown(ctx); err != nil { + log.Error("Server forced to shutdown", slog.Any("error", err)) + } + } + + if tlsSrv != nil { + if err := tlsSrv.Shutdown(ctx); err != nil { + log.Error("REST API TLS server forced to shutdown", slog.Any("error", err)) + } } xdsServer.Stop() diff --git a/gateway/gateway-controller/cmd/controller/server_tls.go b/gateway/gateway-controller/cmd/controller/server_tls.go new file mode 100644 index 0000000000..e5b10b2571 --- /dev/null +++ b/gateway/gateway-controller/cmd/controller/server_tls.go @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package main + +import ( + "crypto/tls" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" +) + +// buildRESTAPITLSConfig translates a config.ServerTLSConfig into a tls.Config: +// bounded protocol version range, an optional cipher-suite restriction +// (TLS 1.2 and below only — TLS 1.3 suite selection isn't configurable in +// Go's crypto/tls), and the ECDH/group preference list, PQC hybrid group +// included when the operator has opted in. Config.Validate already rejects a +// bad version/cipher/curve value before this ever runs in production, so an +// error here can only come from a caller that bypassed validation. +func buildRESTAPITLSConfig(cfg *config.ServerTLSConfig) (*tls.Config, error) { + if err := config.ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseServerTLSVersion(cfg.MinimumProtocolVersion) + maxVersion, _ := config.ParseServerTLSVersion(cfg.MaximumProtocolVersion) + + cipherSuites, err := config.ParseServerCiphers(cfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseServerEcdhCurves(cfg.EcdhCurves) + if err != nil { + return nil, err + } + + return &tls.Config{ + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil +} diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index 5529c72806..6e3604fb87 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -34,8 +34,8 @@ import ( commonmodels "github.com/wso2/api-platform/common/models" "github.com/wso2/api-platform/common/redact" adminapi "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/admin" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/handlers/handlerkit" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/middleware" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" @@ -99,7 +99,8 @@ func NewAPIServer( subscriptionSnapshotUpdater utils.SubscriptionSnapshotUpdater, secretService *secrets.SecretService, restAPIService *restapi.RestAPIService, -) *APIServer { + httpClient *http.Client, +) (*APIServer, error) { if db == nil { panic("APIServer requires non-nil storage") } @@ -114,14 +115,13 @@ func NewAPIServer( panic("APIServer requires non-empty gateway ID") } - deploymentService := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, &systemConfig.Router, eventHub, gatewayID, secretService) + deploymentService := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, &systemConfig.Router, eventHub, gatewayID, secretService, httpClient) apiKeyService := utils.NewAPIKeyService(store, db, apiKeyXDSManager, &systemConfig.APIKey, eventHub, gatewayID) subscriptionResourceService := utils.NewSubscriptionResourceService(db, subscriptionSnapshotUpdater, eventHub, gatewayID) policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) policyValidator := config.NewPolicyValidator(policyDefinitions) parser := config.NewParser() - httpClient := &http.Client{Timeout: 10 * time.Second} routerConfig := &systemConfig.Router mcpDeploymentService := utils.NewMCPDeploymentService(store, db, snapshotManager, policyManager, policyValidator, eventHub, gatewayID, secretService) @@ -167,7 +167,7 @@ func NewAPIServer( // Register status update callback snapshotManager.SetStatusCallback(server.handleStatusUpdate) - return server + return server, nil } func (s *APIServer) getSubscriptionResourceService() *utils.SubscriptionResourceService { diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go index f44f7163b4..a1a10f2469 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go @@ -1140,7 +1140,7 @@ func createTestAPIServerWithDB(db storage.Storage) *APIServer { gatewayID: gatewayID, } - deploymentService := utils.NewAPIDeploymentService(store, db, nil, validator, routerCfg, hub, gatewayID, nil) + deploymentService := utils.NewAPIDeploymentService(store, db, nil, validator, routerCfg, hub, gatewayID, nil, httpClient) server.deploymentService = deploymentService server.mcpDeploymentService = utils.NewMCPDeploymentService(store, db, nil, nil, nil, hub, gatewayID, nil) server.llmDeploymentService = utils.NewLLMDeploymentService( @@ -1379,7 +1379,7 @@ func attachTestEventHub(server *APIServer, hub eventhub.EventHub, gatewayID stri } policyValidator := config.NewPolicyValidator(server.policyDefinitions) policyVersionResolver := utils.NewLoadedPolicyVersionResolver(server.policyDefinitions) - server.deploymentService = utils.NewAPIDeploymentService(server.store, server.db, server.snapshotManager, server.validator, server.routerConfig, hub, gatewayID, nil) + server.deploymentService = utils.NewAPIDeploymentService(server.store, server.db, server.snapshotManager, server.validator, server.routerConfig, hub, gatewayID, nil, server.httpClient) server.apiKeyService = utils.NewAPIKeyService(server.store, server.db, server.apiKeyXDSManager, &server.systemConfig.APIKey, hub, gatewayID) server.subscriptionResourceService = utils.NewSubscriptionResourceService(server.db, server.subscriptionSnapshotUpdater, hub, gatewayID) server.mcpDeploymentService = utils.NewMCPDeploymentService(server.store, server.db, server.snapshotManager, server.policyManager, policyValidator, hub, gatewayID, nil) diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index f7cbea8276..3fbfcf29e6 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -200,6 +200,7 @@ type Controller struct { Storage StorageConfig `koanf:"storage"` Logging LoggingConfig `koanf:"logging"` ControlPlane ControlPlaneConfig `koanf:"controlplane"` + HTTPClient HTTPClientConfig `koanf:"http_client"` PolicyServer PolicyServerConfig `koanf:"policy_server"` Policies PoliciesConfig `koanf:"policies"` LLM LLMConfig `koanf:"llm"` @@ -305,6 +306,88 @@ type ServerConfig struct { ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` GatewayID string `koanf:"gateway_id"` SkipInvalidDeploymentsOnStartup bool `koanf:"skip_invalid_deployments_on_startup"` + + // TLS starts a second, TLS-only listener on TLS.Port serving the same + // REST management API as the plaintext listener on APIPort. Off by + // default. + TLS ServerTLSConfig `koanf:"tls"` + + // XDSTLS switches the main xDS gRPC server (serving Envoy, on XDSPort) + // from plaintext to mutual TLS. Unlike TLS above, this does not add a + // second listener -- XDSPort itself starts speaking mTLS. Off by + // default; see XDSServerTLSConfig for why xDS has no server-only mode. + XDSTLS XDSServerTLSConfig `koanf:"xds_tls"` + + // ReadTimeout, ReadHeaderTimeout, WriteTimeout, and IdleTimeout bound the + // REST management API's http.Server (both the plaintext listener on + // APIPort and the TLS listener on TLS.Port) so a slow or malicious + // client can't hold a connection open indefinitely (Slowloris-style + // resource exhaustion). MaxHeaderBytes bounds header size the same way. + // All five must be non-zero -- defaultConfig supplies safe defaults. + ReadTimeout time.Duration `koanf:"read_timeout"` + ReadHeaderTimeout time.Duration `koanf:"read_header_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + MaxHeaderBytes int `koanf:"max_header_bytes"` +} + +// ServerTLSConfig holds configuration for an additional TLS listener for the +// REST management API. It is served alongside — not instead of — the +// plaintext listener on ServerConfig.APIPort, so enabling it never breaks an +// existing plaintext deployment. Same shape and naming conventions as +// policy-engine's AdminTLSConfig (gateway-runtime/policy-engine/internal/config) — +// keep the two in sync if either changes, they are independent implementations +// (different Go modules) of the same pattern. +type ServerTLSConfig struct { + // Enabled starts the TLS listener on Port. Off by default: no + // certificate is provisioned by default, and the plaintext listener + // keeps working either way. + Enabled bool `koanf:"enabled"` + + // Port is the port for the TLS REST API listener. Must differ from every + // other configured controller port (server.api_port, server.xds_port, + // admin_server.port, metrics.port). + Port int `koanf:"port"` + + // CertPath and KeyPath are the PEM-encoded server certificate and + // private key for the TLS listener. Required when Enabled. + CertPath string `koanf:"cert_path"` + KeyPath string `koanf:"key_path"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the negotiated + // TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". Same + // vocabulary as router.downstream_tls/upstream_tls for consistency + // within the shared config file. + MinimumProtocolVersion string `koanf:"minimum_protocol_version"` + MaximumProtocolVersion string `koanf:"maximum_protocol_version"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which + // suites this listener will negotiate. Empty by default, meaning Go's + // own secure default set/order applies. Only affects TLS 1.2 and below — + // TLS 1.3 suite selection is not configurable in Go's crypto/tls. + // + // Note this is a different naming scheme than router.downstream_tls's + // ciphers field (OpenSSL/BoringSSL names like + // "ECDHE-ECDSA-AES128-GCM-SHA256"): this listener is served by Go's own + // crypto/tls, not Envoy, so it uses Go's canonical cipher suite names — + // see crypto/tls.CipherSuites for the supported list. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups, + // most preferred first. Defaults to the hybrid post-quantum group + // ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) first, with classical + // fallbacks after (e.g. "X25519MLKEM768,X25519,P-256"). + // + // Unlike router.downstream_tls/upstream_tls's EcdhCurves (classical-only + // by default), this listener is served directly by this process's own Go + // crypto/tls (1.23+ implements X25519MLKEM768 natively) rather than + // pushed as xDS config to a separate Envoy process, so defaulting to the + // hybrid group here carries none of the "already-running peer NACKs the + // update" risk documented on those fields — TLS 1.3 negotiation simply + // falls back to a later classical entry in this same list for a client + // that doesn't offer the hybrid group. + EcdhCurves string `koanf:"ecdh_curves"` } // AdminServerConfig holds controller admin HTTP server configuration. @@ -337,15 +420,8 @@ type PprofConfig struct { // PolicyServerConfig holds policy xDS server-related configuration type PolicyServerConfig struct { - Port int `koanf:"port"` - TLS PolicyServerTLS `koanf:"tls"` -} - -// PolicyServerTLS holds TLS configuration for the policy xDS server -type PolicyServerTLS struct { - Enabled bool `koanf:"enabled"` - CertFile string `koanf:"cert_file"` - KeyFile string `koanf:"key_file"` + Port int `koanf:"port"` + TLS XDSServerTLSConfig `koanf:"tls"` } // PoliciesConfig holds policy-related configuration @@ -564,12 +640,15 @@ type UpstreamTLS struct { MinimumProtocolVersion string `koanf:"minimum_protocol_version"` MaximumProtocolVersion string `koanf:"maximum_protocol_version"` Ciphers string `koanf:"ciphers"` - // EcdhCurves is a comma-separated list of ECDH curves, most preferred first. Defaults to a - // hybrid post-quantum group ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) followed by - // classical curves, so key exchange degrades gracefully to classical for peers that don't yet - // support the hybrid group. - EcdhCurves string `koanf:"ecdh_curves"` - TrustedCertPath string `koanf:"trusted_cert_path"` + // EcdhCurves is a comma-separated list of ECDH curves (e.g. "X25519,P-256"), most preferred + // first. Defaults to classical curves only — a hybrid post-quantum group (e.g. + // "X25519MLKEM768") can be added as the first preference, but only as an explicit opt-in per + // deployment: an already-running Envoy instance that doesn't recognize the curve name will + // NACK the xDS update and keep serving its last-known-good config, silently freezing that + // instance out of any further config changes until the operator fixes it. Confirm the + // deployed Envoy/BoringSSL build supports the group before enabling it. + EcdhCurves string `koanf:"ecdh_curves"` + TrustedCertPath string `koanf:"trusted_cert_path"` CustomCertsPath string `koanf:"custom_certs_path"` // Directory containing custom trusted certificates VerifyHostName bool `koanf:"verify_host_name"` DisableSslVerification bool `koanf:"disable_ssl_verification"` @@ -599,10 +678,13 @@ type DownstreamTLS struct { MinimumProtocolVersion string `koanf:"minimum_protocol_version"` MaximumProtocolVersion string `koanf:"maximum_protocol_version"` Ciphers string `koanf:"ciphers"` - // EcdhCurves is a comma-separated list of ECDH curves, most preferred first. Defaults to a - // hybrid post-quantum group ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) followed by - // classical curves, so key exchange degrades gracefully to classical for peers that don't yet - // support the hybrid group. + // EcdhCurves is a comma-separated list of ECDH curves (e.g. "X25519,P-256"), most preferred + // first. Defaults to classical curves only — a hybrid post-quantum group (e.g. + // "X25519MLKEM768") can be added as the first preference, but only as an explicit opt-in per + // deployment: an already-running Envoy instance that doesn't recognize the curve name will + // NACK the xDS update and keep serving its last-known-good config, silently freezing that + // instance out of any further config changes until the operator fixes it. Confirm the + // deployed Envoy/BoringSSL build supports the group before enabling it. EcdhCurves string `koanf:"ecdh_curves"` } @@ -694,6 +776,117 @@ type ControlPlaneConfig struct { GatewayName string `koanf:"gateway_name"` // Name of the gateway for deployment configuration } +// HTTPClientConfig configures the single shared outbound *http.Client used by every +// control-plane / platform-API / on-prem-APIM call this process makes. It mirrors +// github.com/wso2/api-platform/httpkit/httpclient.Config field-for-field (see that package's own doc +// comments for full semantics) so every knob the library exposes that has a natural TOML +// shape is operator-configurable here, rather than hardcoded in main.go. The few fields +// httpclient.Config exposes that CANNOT be expressed in TOML — Go callback hooks +// (GetClientCertificate, VerifyPeerCertificate, VerifyConnection, ConnectHeader) and a +// pre-built *x509.CertPool / []*net.IPNet — are not represented here; a caller that needs +// those uses the httpclient package directly in code. +// +// Timeouts.Overall is only a generous safety-net budget: real per-operation budgets (5s +// well-known discovery, 30s manifest/platform-API/on-prem-APIM calls, etc.) are enforced via +// a context.WithTimeout deadline at each call site, since http.Client.Do honors a request's +// context deadline independent of the client-level Timeout. +// +// TLS.InsecureSkipVerify is intentionally NOT a field here — it is sourced from the single +// existing controller.controlplane.insecure_skip_verify setting (see main.go), which already +// governs this same trust decision for every one of this client's current callers (control +// plane, platform API, and on-prem APIM all copy that one field today). Duplicating it here +// would just create two settings that must always be kept in sync. +type HTTPClientConfig struct { + Pooling HTTPClientPoolingConfig `koanf:"pooling"` + Timeouts HTTPClientTimeoutsConfig `koanf:"timeouts"` + TLS HTTPClientTLSConfig `koanf:"tls"` + Proxy HTTPClientProxyConfig `koanf:"proxy"` + SSRF HTTPClientSSRFConfig `koanf:"ssrf"` +} + +// HTTPClientPoolingConfig mirrors httpclient.PoolingConfig. +type HTTPClientPoolingConfig struct { + MaxIdleConns int `koanf:"max_idle_conns"` + MaxIdleConnsPerHost int `koanf:"max_idle_conns_per_host"` + MaxConnsPerHost int `koanf:"max_conns_per_host"` + IdleConnTimeout time.Duration `koanf:"idle_conn_timeout"` + KeepAlive time.Duration `koanf:"keep_alive"` + DisableKeepAlives bool `koanf:"disable_keep_alives"` + // EnableHTTP2 opts into HTTP/2. See httpclient.PoolingConfig.EnableHTTP2's doc comment + // on the HTTP/2 connection-coalescing caveat before enabling. + EnableHTTP2 bool `koanf:"enable_http2"` +} + +// HTTPClientTimeoutsConfig mirrors httpclient.TimeoutsConfig. +type HTTPClientTimeoutsConfig struct { + Overall time.Duration `koanf:"overall"` // safety-net only; see HTTPClientConfig's doc comment + Dial time.Duration `koanf:"dial"` + TLSHandshake time.Duration `koanf:"tls_handshake"` + ResponseHeader time.Duration `koanf:"response_header"` + ExpectContinue time.Duration `koanf:"expect_continue"` + MaxResponseBytes int64 `koanf:"max_response_bytes"` // 0 = package default (10MiB); negative disables the bound +} + +// HTTPClientTLSConfig mirrors the TOML-expressible subset of httpclient.TLSConfig. +type HTTPClientTLSConfig struct { + MinVersion string `koanf:"min_version"` // one of "TLS1_0".."TLS1_3" + MaxVersion string `koanf:"max_version"` // one of "TLS1_0".."TLS1_3" + CipherSuites string `koanf:"cipher_suites"` // comma-separated Go crypto/tls cipher suite names; TLS 1.2 and below only + CurvePreferences string `koanf:"curve_preferences"` // comma-separated, e.g. "X25519MLKEM768,X25519,P-256" + RootCAFile string `koanf:"root_ca_file"` // PEM CA bundle; empty uses the system root pool + ClientCertFile string `koanf:"client_cert_file"` // mTLS to the origin; both cert and key must be set together + ClientKeyFile string `koanf:"client_key_file"` +} + +// HTTPClientProxyConfig mirrors the TOML-expressible subset of httpclient.ProxyConfig. +type HTTPClientProxyConfig struct { + // Mode selects how the proxy is determined: "none" (default), "environment" + // (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), or "url" (URL/Username/Password/NoProxy below). + Mode string `koanf:"mode"` + URL string `koanf:"url"` + Username string `koanf:"username"` + Password string `koanf:"password"` + NoProxy []string `koanf:"no_proxy"` // exact host, ".suffix", or CIDR entries; only used when mode == "url" + + TLS HTTPClientProxyTLSConfig `koanf:"tls"` + + // Egress states how origin-destination SSRF risk is handled when a forward proxy is + // also configured: "delegated" (trust the proxy's own egress controls) or + // "manual_connect" (validate the origin locally before ever issuing CONNECT). Must be + // set explicitly whenever Mode != "none" and SSRF.Enabled — httpclient.New fails + // closed at startup otherwise rather than silently choosing one. + Egress string `koanf:"egress"` +} + +// HTTPClientProxyTLSConfig mirrors httpclient.ProxyTLSConfig (the proxy's own TLS +// handshake, fully decoupled from the origin TLS handshake in HTTPClientTLSConfig). +type HTTPClientProxyTLSConfig struct { + RootCAFile string `koanf:"root_ca_file"` + ClientCertFile string `koanf:"client_cert_file"` + ClientKeyFile string `koanf:"client_key_file"` + InsecureSkipVerify bool `koanf:"insecure_skip_verify"` +} + +// HTTPClientSSRFConfig mirrors the TOML-expressible subset of httpclient.SSRFConfig. Off by +// default: this shared client's current callers (control plane, platform API, on-prem APIM) +// all target a single fixed, operator-configured host, not a user/tenant-supplied URL — the +// scenario ssrf-prevention.md targets — so there is nothing to guard against today. Exposed +// for completeness and for any future caller of this shared client that fetches a +// tenant-supplied URL. +type HTTPClientSSRFConfig struct { + Enabled bool `koanf:"enabled"` + // Preset selects a built-in netguard policy: "permit_private_block_metadata" (a + // backend that is normally private — a ClusterIP, a service-DNS name, localhost — + // stays reachable; only link-local/metadata/unspecified/multicast are refused) or + // "public_only" (stricter: every private/loopback/link-local/CGNAT address is + // refused, for a URL expected to point at the public internet). Required when Enabled + // is true. Custom CIDR allow/deny lists have no natural TOML shape and are not + // exposed here — use the httpclient/netguard packages directly in code for that. + Preset string `koanf:"preset"` + MaxRedirects int `koanf:"max_redirects"` + AllowedSchemes []string `koanf:"allowed_schemes"` // empty defaults to {"https"} +} + // APIKeyConfig represents the configuration for API keys type APIKeyConfig struct { APIKeysPerUserPerAPI int `koanf:"api_keys_per_user_per_api"` // Number of API keys allowed per user per API @@ -842,6 +1035,29 @@ func defaultConfig() *Config { ShutdownTimeout: 15 * time.Second, GatewayID: constants.PlatformGatewayId, SkipInvalidDeploymentsOnStartup: false, + ReadTimeout: 30 * time.Second, + ReadHeaderTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1 MiB + TLS: ServerTLSConfig{ + Enabled: false, + Port: 9093, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + }, + XDSTLS: XDSServerTLSConfig{ + Enabled: false, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + }, }, AdminServer: AdminServerConfig{ Enabled: true, @@ -858,10 +1074,15 @@ func defaultConfig() *Config { }, PolicyServer: PolicyServerConfig{ Port: 18001, - TLS: PolicyServerTLS{ - Enabled: false, - CertFile: "./certs/server.crt", - KeyFile: "./certs/server.key", + TLS: XDSServerTLSConfig{ + Enabled: false, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519MLKEM768,X25519,P-256", }, }, Policies: PoliciesConfig{ @@ -923,6 +1144,33 @@ func defaultConfig() *Config { AIWorkspaceSyncPoolSize: 0, AIWorkspaceSyncQueueSize: 0, }, + HTTPClient: HTTPClientConfig{ + Pooling: HTTPClientPoolingConfig{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + MaxConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + KeepAlive: 30 * time.Second, + }, + Timeouts: HTTPClientTimeoutsConfig{ + Overall: 60 * time.Second, // safety-net only; see HTTPClientConfig's doc comment + Dial: 10 * time.Second, + TLSHandshake: 10 * time.Second, + ResponseHeader: 10 * time.Second, + ExpectContinue: 1 * time.Second, + }, + TLS: HTTPClientTLSConfig{ + MinVersion: "TLS1_2", + MaxVersion: "TLS1_3", + CurvePreferences: "", + }, + Proxy: HTTPClientProxyConfig{ + Mode: "none", + }, + SSRF: HTTPClientSSRFConfig{ + Enabled: false, + }, + }, EventHub: EventHubConfig{ PollInterval: 3 * time.Second, CleanupInterval: 10 * time.Minute, @@ -999,7 +1247,7 @@ func defaultConfig() *Config { MinimumProtocolVersion: "TLS1_2", MaximumProtocolVersion: "TLS1_3", Ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA", - EcdhCurves: "X25519MLKEM768,X25519,P-256", + EcdhCurves: "X25519,P-256", }, GatewayHost: "*", Upstream: RouterUpstream{ @@ -1007,7 +1255,7 @@ func defaultConfig() *Config { MinimumProtocolVersion: "TLS1_2", MaximumProtocolVersion: "TLS1_3", Ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA", - EcdhCurves: "X25519MLKEM768,X25519,P-256", + EcdhCurves: "X25519,P-256", TrustedCertPath: "/etc/ssl/certs/ca-certificates.crt", CustomCertsPath: "./certificates", VerifyHostName: true, @@ -1367,6 +1615,60 @@ func (c *Config) Validate() error { return fmt.Errorf("server.gateway_id is required and cannot be empty") } + if c.Controller.Server.ReadTimeout <= 0 { + return fmt.Errorf("server.read_timeout must be positive, got: %s", c.Controller.Server.ReadTimeout) + } + if c.Controller.Server.ReadHeaderTimeout <= 0 { + return fmt.Errorf("server.read_header_timeout must be positive, got: %s", c.Controller.Server.ReadHeaderTimeout) + } + if c.Controller.Server.WriteTimeout <= 0 { + return fmt.Errorf("server.write_timeout must be positive, got: %s", c.Controller.Server.WriteTimeout) + } + if c.Controller.Server.IdleTimeout <= 0 { + return fmt.Errorf("server.idle_timeout must be positive, got: %s", c.Controller.Server.IdleTimeout) + } + if c.Controller.Server.MaxHeaderBytes <= 0 { + return fmt.Errorf("server.max_header_bytes must be positive, got: %d", c.Controller.Server.MaxHeaderBytes) + } + + // Validate REST API TLS config + if c.Controller.Server.TLS.Enabled { + if c.Controller.Server.TLS.Port < 1 || c.Controller.Server.TLS.Port > 65535 { + return fmt.Errorf("server.tls.port must be between 1 and 65535, got: %d", c.Controller.Server.TLS.Port) + } + if c.Controller.Server.TLS.Port == c.Controller.Server.APIPort { + return fmt.Errorf("server.tls.port cannot be same as server.api_port") + } + if c.Controller.Server.TLS.Port == c.Controller.Server.XDSPort { + return fmt.Errorf("server.tls.port cannot be same as server.xds_port") + } + if c.Controller.Server.TLS.CertPath == "" { + return fmt.Errorf("server.tls.cert_path is required when server.tls.enabled") + } + if c.Controller.Server.TLS.KeyPath == "" { + return fmt.Errorf("server.tls.key_path is required when server.tls.enabled") + } + if err := ValidateServerTLSVersions(c.Controller.Server.TLS.MinimumProtocolVersion, c.Controller.Server.TLS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("server.tls: %w", err) + } + if _, err := ParseServerCiphers(c.Controller.Server.TLS.Ciphers); err != nil { + return fmt.Errorf("server.tls.ciphers: %w", err) + } + if _, err := ParseServerEcdhCurves(c.Controller.Server.TLS.EcdhCurves); err != nil { + return fmt.Errorf("server.tls.ecdh_curves: %w", err) + } + } + + // Validate main xDS server mTLS config (serves Envoy on server.xds_port) + if err := ValidateXDSServerTLS("server.xds_tls", c.Controller.Server.XDSTLS); err != nil { + return err + } + + // Validate policy xDS server mTLS config (serves the policy-engine on policy_server.port) + if err := ValidateXDSServerTLS("policy_server.tls", c.Controller.PolicyServer.TLS); err != nil { + return err + } + if c.Controller.AdminServer.Enabled { if c.Controller.AdminServer.Port < 1 || c.Controller.AdminServer.Port > 65535 { return fmt.Errorf("admin_server.port must be between 1 and 65535, got: %d", c.Controller.AdminServer.Port) @@ -1377,6 +1679,9 @@ func (c *Config) Validate() error { if c.Controller.AdminServer.Port == c.Controller.Server.XDSPort { return fmt.Errorf("admin_server.port cannot be same as server.xds_port") } + if c.Controller.Server.TLS.Enabled && c.Controller.AdminServer.Port == c.Controller.Server.TLS.Port { + return fmt.Errorf("admin_server.port cannot be same as server.tls.port") + } } // Validate metrics config @@ -1393,6 +1698,9 @@ func (c *Config) Validate() error { if c.Controller.AdminServer.Enabled && c.Controller.Metrics.Port == c.Controller.AdminServer.Port { return fmt.Errorf("metrics.port cannot be same as admin_server.port") } + if c.Controller.Server.TLS.Enabled && c.Controller.Metrics.Port == c.Controller.Server.TLS.Port { + return fmt.Errorf("metrics.port cannot be same as server.tls.port") + } } if c.Router.ListenerPort < 1 || c.Router.ListenerPort > 65535 { diff --git a/gateway/gateway-controller/pkg/config/config_test.go b/gateway/gateway-controller/pkg/config/config_test.go index 2fbebb5417..259e56aa00 100644 --- a/gateway/gateway-controller/pkg/config/config_test.go +++ b/gateway/gateway-controller/pkg/config/config_test.go @@ -19,6 +19,7 @@ package config import ( + "crypto/tls" "os" "path/filepath" "strings" @@ -36,9 +37,14 @@ func validConfig() *Config { return &Config{ Controller: Controller{ Server: ServerConfig{ - APIPort: 8080, - XDSPort: 18000, - GatewayID: constants.PlatformGatewayId, + APIPort: 8080, + XDSPort: 18000, + GatewayID: constants.PlatformGatewayId, + ReadTimeout: 30 * time.Second, + ReadHeaderTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, }, Storage: StorageConfig{ Type: "sqlite", @@ -664,6 +670,330 @@ func TestConfig_Validate_Ports(t *testing.T) { } } +func TestConfig_Validate_ServerTLS(t *testing.T) { + validTLS := func() ServerTLSConfig { + return ServerTLSConfig{ + Enabled: true, + Port: 9093, + CertPath: "./certs/rest-api.crt", + KeyPath: "./certs/rest-api.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + tests := []struct { + name string + mutate func(*ServerTLSConfig) + wantErr bool + errContains string + }{ + {name: "valid config", mutate: func(tls *ServerTLSConfig) {}, wantErr: false}, + { + name: "PQC hybrid group opt-in", + mutate: func(tls *ServerTLSConfig) { tls.EcdhCurves = "X25519MLKEM768,X25519,P-256" }, + wantErr: false, + }, + { + name: "restricted cipher suite list", + mutate: func(tls *ServerTLSConfig) { + tls.Ciphers = "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + }, + wantErr: false, + }, + { + name: "invalid port zero", + mutate: func(tls *ServerTLSConfig) { tls.Port = 0 }, + wantErr: true, + errContains: "server.tls.port must be between", + }, + { + name: "port conflicts with api_port", + mutate: func(tls *ServerTLSConfig) { tls.Port = 8080 }, + wantErr: true, + errContains: "server.tls.port cannot be same as server.api_port", + }, + { + name: "port conflicts with xds_port", + mutate: func(tls *ServerTLSConfig) { tls.Port = 18000 }, + wantErr: true, + errContains: "server.tls.port cannot be same as server.xds_port", + }, + { + name: "missing cert path", + mutate: func(tls *ServerTLSConfig) { tls.CertPath = "" }, + wantErr: true, + errContains: "server.tls.cert_path is required", + }, + { + name: "missing key path", + mutate: func(tls *ServerTLSConfig) { tls.KeyPath = "" }, + wantErr: true, + errContains: "server.tls.key_path is required", + }, + { + name: "missing minimum protocol version", + mutate: func(tls *ServerTLSConfig) { tls.MinimumProtocolVersion = "" }, + wantErr: true, + errContains: "minimum_protocol_version", + }, + { + name: "minimum protocol version greater than maximum", + mutate: func(tls *ServerTLSConfig) { + tls.MinimumProtocolVersion = "TLS1_3" + tls.MaximumProtocolVersion = "TLS1_2" + }, + wantErr: true, + errContains: "cannot be greater than maximum_protocol_version", + }, + { + name: "unsupported cipher suite", + mutate: func(tls *ServerTLSConfig) { tls.Ciphers = "TLS_RSA_WITH_RC4_128_SHA" }, + wantErr: true, + errContains: "server.tls.ciphers", + }, + { + name: "unsupported ecdh curve", + mutate: func(tls *ServerTLSConfig) { tls.EcdhCurves = "not-a-curve" }, + wantErr: true, + errContains: "server.tls.ecdh_curves", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + tlsCfg := validTLS() + tt.mutate(&tlsCfg) + cfg.Controller.Server.TLS = tlsCfg + + err := cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } + + t.Run("disabled - no validation", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.TLS = ServerTLSConfig{Enabled: false, Port: 0} // invalid but should pass since disabled + assert.NoError(t, cfg.Validate()) + }) + + t.Run("conflicts with admin_server.port", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + cfg.Controller.AdminServer.Enabled = true + cfg.Controller.AdminServer.Port = 9092 + tlsCfg := validTLS() + tlsCfg.Port = 9092 + cfg.Controller.Server.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "admin_server.port cannot be same as server.tls.port") + }) + + t.Run("conflicts with metrics.port", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + cfg.Controller.Metrics.Enabled = true + cfg.Controller.Metrics.Port = 9091 + tlsCfg := validTLS() + tlsCfg.Port = 9091 + cfg.Controller.Server.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "metrics.port cannot be same as server.tls.port") + }) +} + +// TestConfig_Validate_XDSServerTLS verifies Config.Validate() routes +// server.xds_tls and policy_server.tls through ValidateXDSServerTLS. +func TestConfig_Validate_XDSServerTLS(t *testing.T) { + validXDSTLS := func() XDSServerTLSConfig { + return XDSServerTLSConfig{ + Enabled: true, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + AllowedClientIdentities: []string{"spiffe://api-platform/gateway-runtime/envoy"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + t.Run("valid server.xds_tls passes", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.XDSTLS = validXDSTLS() + assert.NoError(t, cfg.Validate()) + }) + + t.Run("invalid server.xds_tls is rejected with a prefixed error", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.AllowedClientIdentities = nil + cfg.Controller.Server.XDSTLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "server.xds_tls.allowed_client_identities") + }) + + t.Run("valid policy_server.tls passes", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.AllowedClientIdentities = []string{"spiffe://api-platform/gateway-runtime/policy-engine"} + cfg.Controller.PolicyServer.TLS = tlsCfg + assert.NoError(t, cfg.Validate()) + }) + + t.Run("invalid policy_server.tls is rejected with a prefixed error", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.ClientCAFile = "" + cfg.Controller.PolicyServer.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "policy_server.tls.client_ca_file") + }) + + t.Run("disabled by default -- no validation", func(t *testing.T) { + cfg := validConfig() + assert.NoError(t, cfg.Validate()) + }) +} + +// TestParseServerEcdhCurves tests the ECDH curve preference parser used by +// ServerTLSConfig.EcdhCurves. +func TestParseServerEcdhCurves(t *testing.T) { + t.Run("classical curves only", func(t *testing.T) { + curves, err := ParseServerEcdhCurves("X25519,P-256") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("PQC hybrid group prepended", func(t *testing.T) { + curves, err := ParseServerEcdhCurves("X25519MLKEM768,X25519,P-256") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("whitespace tolerated", func(t *testing.T) { + curves, err := ParseServerEcdhCurves(" X25519 , P-256 ") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("unsupported curve name rejected", func(t *testing.T) { + _, err := ParseServerEcdhCurves("X25519,not-a-curve") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported ecdh curve") + }) + + t.Run("empty string rejected", func(t *testing.T) { + _, err := ParseServerEcdhCurves("") + assert.Error(t, err) + }) +} + +// TestValidateServerTLSVersions tests the min/max protocol version +// validation used by ServerTLSConfig. +func TestValidateServerTLSVersions(t *testing.T) { + t.Run("valid TLS1_2 to TLS1_3 range", func(t *testing.T) { + assert.NoError(t, ValidateServerTLSVersions("TLS1_2", "TLS1_3")) + }) + + t.Run("equal min and max", func(t *testing.T) { + assert.NoError(t, ValidateServerTLSVersions("TLS1_2", "TLS1_2")) + }) + + t.Run("unrecognized minimum version", func(t *testing.T) { + err := ValidateServerTLSVersions("bogus", "TLS1_3") + assert.Error(t, err) + assert.Contains(t, err.Error(), "minimum_protocol_version") + }) + + t.Run("unrecognized maximum version", func(t *testing.T) { + err := ValidateServerTLSVersions("TLS1_2", "bogus") + assert.Error(t, err) + assert.Contains(t, err.Error(), "maximum_protocol_version") + }) + + t.Run("minimum greater than maximum", func(t *testing.T) { + err := ValidateServerTLSVersions("TLS1_3", "TLS1_2") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be greater than maximum_protocol_version") + }) +} + +// TestParseServerTLSVersion tests the version-name to crypto/tls-identifier +// conversion used by ServerTLSConfig. +func TestParseServerTLSVersion(t *testing.T) { + tests := []struct { + name string + version string + want uint16 + }{ + {"TLS1_0", "TLS1_0", tls.VersionTLS10}, + {"TLS1_1", "TLS1_1", tls.VersionTLS11}, + {"TLS1_2", "TLS1_2", tls.VersionTLS12}, + {"TLS1_3", "TLS1_3", tls.VersionTLS13}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ParseServerTLSVersion(tt.version) + require.True(t, ok) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("unrecognized version", func(t *testing.T) { + _, ok := ParseServerTLSVersion("bogus") + assert.False(t, ok) + }) +} + +// TestParseServerCiphers tests the cipher-suite-name parser used by +// ServerTLSConfig.Ciphers. +func TestParseServerCiphers(t *testing.T) { + t.Run("empty string is valid and means Go's defaults", func(t *testing.T) { + suites, err := ParseServerCiphers("") + require.NoError(t, err) + assert.Nil(t, suites) + }) + + t.Run("restricts to the named secure suites", func(t *testing.T) { + suites, err := ParseServerCiphers("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("whitespace tolerated", func(t *testing.T) { + suites, err := ParseServerCiphers(" TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 ") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("insecure cipher suite rejected", func(t *testing.T) { + _, err := ParseServerCiphers("TLS_RSA_WITH_RC4_128_SHA") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported or insecure cipher suite") + }) + + t.Run("unrecognized cipher suite name rejected", func(t *testing.T) { + _, err := ParseServerCiphers("NOT_A_REAL_SUITE") + assert.Error(t, err) + }) +} + func TestConfig_Validate_MetricsConfig(t *testing.T) { tests := []struct { name string @@ -1812,14 +2142,26 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, 5*time.Minute, hcm.StreamIdleTimeout, "default stream_idle_timeout should be 5m") assert.Equal(t, time.Hour, hcm.IdleTimeout, "default idle_timeout should be 1h") - // TLS 1.2-1.3 with a hybrid post-quantum + classical ECDH curve preference - // list must be available by default on both upstream and downstream. + // TLS 1.2-1.3 must be available by default on both upstream and downstream. + // ECDH curves default to classical only (X25519, P-256): prepending a hybrid + // post-quantum group is an explicit per-deployment opt-in (see the EcdhCurves + // field doc), not a default, because an already-running Envoy instance that + // doesn't recognize the curve name would NACK the xDS update rather than + // picking up the change. assert.Equal(t, "TLS1_2", cfg.Router.DownstreamTLS.MinimumProtocolVersion) assert.Equal(t, "TLS1_3", cfg.Router.DownstreamTLS.MaximumProtocolVersion) - assert.Equal(t, "X25519MLKEM768,X25519,P-256", cfg.Router.DownstreamTLS.EcdhCurves) + assert.Equal(t, "X25519,P-256", cfg.Router.DownstreamTLS.EcdhCurves) assert.Equal(t, "TLS1_2", cfg.Router.Upstream.TLS.MinimumProtocolVersion) assert.Equal(t, "TLS1_3", cfg.Router.Upstream.TLS.MaximumProtocolVersion) - assert.Equal(t, "X25519MLKEM768,X25519,P-256", cfg.Router.Upstream.TLS.EcdhCurves) + assert.Equal(t, "X25519,P-256", cfg.Router.Upstream.TLS.EcdhCurves) + + // Unlike the Envoy-facing router TLS above, Server.TLS, Server.XDSTLS, and + // PolicyServer.TLS are served directly by this process's own Go crypto/tls + // (1.23+ implements X25519MLKEM768 natively), so they default to the hybrid + // PQC group first with classical fallbacks after, rather than classical-only. + assert.Equal(t, "X25519MLKEM768,X25519,P-256", cfg.Controller.Server.TLS.EcdhCurves) + assert.Equal(t, "X25519MLKEM768,X25519,P-256", cfg.Controller.Server.XDSTLS.EcdhCurves) + assert.Equal(t, "X25519MLKEM768,X25519,P-256", cfg.Controller.PolicyServer.TLS.EcdhCurves) } func TestLoadConfig_HCMTimeouts(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/config/httpclient_config.go b/gateway/gateway-controller/pkg/config/httpclient_config.go new file mode 100644 index 0000000000..b16362f2a5 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/httpclient_config.go @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "fmt" + + "github.com/wso2/api-platform/httpkit/httpclient" + "github.com/wso2/api-platform/httpkit/netguard" +) + +// BuildHTTPClientConfig translates an HTTPClientConfig (sourced from +// controller.http_client in config.toml) into an httpkit httpclient.Config, mirroring +// every field the library exposes that has a natural TOML shape. insecureSkipVerify is +// threaded in separately from controller.controlplane.insecure_skip_verify — see +// HTTPClientConfig's doc comment for why that trust setting isn't duplicated here. +// +// This is shared by every binary that loads this package's Config (gateway-controller +// and event-gateway-controller today) so the translation logic is implemented exactly +// once rather than copy-pasted per binary. +func BuildHTTPClientConfig(hc HTTPClientConfig, insecureSkipVerify bool) (httpclient.Config, error) { + cfg := httpclient.DefaultConfig() + + cfg.Pooling.MaxIdleConns = hc.Pooling.MaxIdleConns + cfg.Pooling.MaxIdleConnsPerHost = hc.Pooling.MaxIdleConnsPerHost + cfg.Pooling.MaxConnsPerHost = hc.Pooling.MaxConnsPerHost + cfg.Pooling.IdleConnTimeout = hc.Pooling.IdleConnTimeout + cfg.Pooling.KeepAlive = hc.Pooling.KeepAlive + cfg.Pooling.DisableKeepAlives = hc.Pooling.DisableKeepAlives + cfg.Pooling.EnableHTTP2 = hc.Pooling.EnableHTTP2 + + cfg.Timeouts.Overall = hc.Timeouts.Overall + cfg.Timeouts.Dial = hc.Timeouts.Dial + cfg.Timeouts.TLSHandshake = hc.Timeouts.TLSHandshake + cfg.Timeouts.ResponseHeader = hc.Timeouts.ResponseHeader + cfg.Timeouts.ExpectContinue = hc.Timeouts.ExpectContinue + cfg.Timeouts.MaxResponseBytes = hc.Timeouts.MaxResponseBytes + + cfg.TLS.MinVersion = hc.TLS.MinVersion + cfg.TLS.MaxVersion = hc.TLS.MaxVersion + cfg.TLS.CipherSuites = hc.TLS.CipherSuites + cfg.TLS.CurvePreferences = hc.TLS.CurvePreferences + cfg.TLS.RootCAFile = hc.TLS.RootCAFile + cfg.TLS.ClientCertFile = hc.TLS.ClientCertFile + cfg.TLS.ClientKeyFile = hc.TLS.ClientKeyFile + cfg.TLS.InsecureSkipVerify = insecureSkipVerify // #nosec G402 -- explicit operator-controlled opt-out for dev/test environments. + cfg.TLS.InsecureSkipVerifyAcknowledged = insecureSkipVerify // required double-gate; mirrors InsecureSkipVerify, harmless when false. + + switch hc.Proxy.Mode { + case "", "none": + // no proxy — cfg.Proxy stays at its zero value + case "environment": + cfg.Proxy.Mode = "environment" + case "url": + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = hc.Proxy.URL + cfg.Proxy.Username = hc.Proxy.Username + cfg.Proxy.Password = hc.Proxy.Password + cfg.Proxy.NoProxy = hc.Proxy.NoProxy + if hc.Proxy.TLS != (HTTPClientProxyTLSConfig{}) { + cfg.Proxy.ProxyTLS = &httpclient.ProxyTLSConfig{ + RootCAFile: hc.Proxy.TLS.RootCAFile, + ClientCertFile: hc.Proxy.TLS.ClientCertFile, + ClientKeyFile: hc.Proxy.TLS.ClientKeyFile, + InsecureSkipVerify: hc.Proxy.TLS.InsecureSkipVerify, + InsecureSkipVerifyAcknowledged: hc.Proxy.TLS.InsecureSkipVerify, + } + } + default: + return httpclient.Config{}, fmt.Errorf("controller.http_client.proxy.mode: unrecognized value %q (want \"none\", \"environment\", or \"url\")", hc.Proxy.Mode) + } + + if hc.SSRF.Enabled { + cfg.SSRF.Enabled = true + switch hc.SSRF.Preset { + case "permit_private_block_metadata": + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() + case "public_only": + cfg.SSRF.Policy = netguard.PublicOnly() + default: + return httpclient.Config{}, fmt.Errorf("controller.http_client.ssrf.preset: unrecognized value %q (want \"permit_private_block_metadata\" or \"public_only\")", hc.SSRF.Preset) + } + cfg.SSRF.Policy.AllowedSchemes = hc.SSRF.AllowedSchemes + cfg.SSRF.MaxRedirects = hc.SSRF.MaxRedirects + + if cfg.Proxy.Mode != "" && cfg.Proxy.Mode != "none" { + switch hc.Proxy.Egress { + case "delegated": + cfg.Proxy.Egress = httpclient.ProxyEgressDelegated + case "manual_connect": + cfg.Proxy.Egress = httpclient.ProxyEgressManualCONNECT + default: + return httpclient.Config{}, fmt.Errorf("controller.http_client.proxy.egress must be \"delegated\" or \"manual_connect\" when both proxy and SSRF are enabled, got %q", hc.Proxy.Egress) + } + } + } + + return cfg, nil +} diff --git a/gateway/gateway-controller/pkg/config/server_tls.go b/gateway/gateway-controller/pkg/config/server_tls.go new file mode 100644 index 0000000000..e3f483cf20 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/server_tls.go @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "crypto/tls" + "fmt" + "strings" + + "github.com/wso2/api-platform/httpkit/tlsconfig" +) + +// ParseServerEcdhCurves parses a comma-separated EcdhCurves preference list +// (e.g. "X25519MLKEM768,X25519,P-256") into the tls.CurveID slice consumed by +// tls.Config.CurvePreferences. Used both to fail config validation closed on +// an unrecognized curve name and to build the REST API TLS listener's config. +// +// The name-to-tls.CurveID vocabulary is sourced from httpkit/tlsconfig (the +// shared, direction-neutral implementation); this function keeps its own +// wrapper for the "empty string is an error" behavior, which differs from +// tlsconfig.ParseCurvePreferences's "empty means use Go's defaults" stance — +// an explicit, always-on TLS listener config has no notion of "unset". +func ParseServerEcdhCurves(raw string) ([]tls.CurveID, error) { + parts := strings.Split(raw, ",") + curves := make([]tls.CurveID, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + curve, ok := tlsconfig.CurvesByName[name] + if !ok { + return nil, fmt.Errorf("unsupported ecdh curve %q (supported: X25519, P-256, P-384, P-521, X25519MLKEM768)", name) + } + curves = append(curves, curve) + } + if len(curves) == 0 { + return nil, fmt.Errorf("must specify at least one ecdh curve") + } + return curves, nil +} + +// ValidateServerTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. Unlike +// tlsconfig.ValidateVersionRange (which treats both-empty as "use Go's +// defaults"), this listener's config always requires both fields to name a +// real version — there is no "unset" state for an always-on TLS listener. +// tls.VersionTLSxx constants are monotonically increasing, so comparing the +// parsed values directly replaces a separate ordering table. +func ValidateServerTLSVersions(minVersion, maxVersion string) error { + minV, ok := tlsconfig.ParseVersion(minVersion) + if !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + maxV, ok := tlsconfig.ParseVersion(maxVersion) + if !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if minV > maxV { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseServerTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateServerTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseServerTLSVersion(name string) (version uint16, ok bool) { + return tlsconfig.ParseVersion(name) +} + +// ParseServerCiphers parses a comma-separated list of Go crypto/tls cipher +// suite names (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") into the +// []uint16 consumed by tls.Config.CipherSuites. An empty string is valid and +// returns (nil, nil) — Go's own default suite set/order applies. Only +// affects TLS 1.2 (and below) connections; TLS 1.3 ignores this field. +func ParseServerCiphers(raw string) ([]uint16, error) { + return tlsconfig.ParseCipherSuites(raw) +} diff --git a/gateway/gateway-controller/pkg/config/xds_tls.go b/gateway/gateway-controller/pkg/config/xds_tls.go new file mode 100644 index 0000000000..527f6fed1d --- /dev/null +++ b/gateway/gateway-controller/pkg/config/xds_tls.go @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "strings" +) + +// XDSServerTLSConfig holds mutual-TLS configuration for an xDS gRPC server: +// the main Envoy-facing ADS/SDS server on server.xds_port, and the +// policy-engine-facing server on policy_server.port. Off by default -- both +// servers keep working in plaintext either way, consistent with this +// repo's "PQC/TLS is optional-but-supported, not mandatory" posture (see +// post-quantum-cryptography.md), since not every deployment's Envoy or +// policy-engine build is configured for mTLS yet. +// +// Unlike ServerTLSConfig (the REST management API's TLS listener, which is +// server-only TLS), this type has no server-only mode: xDS is a +// control-plane channel that carries per-tenant API-key hashes, +// subscription state, and full policy chains, so authenticating only the +// server side is not sufficient (go-control-plane-xds-security.md +// directive 2). Whenever Enabled is true, ClientCAFile and +// AllowedClientIdentities are both required -- see ValidateXDSServerTLS. +type XDSServerTLSConfig struct { + // Enabled switches the xDS server from plaintext to mutual TLS on its + // existing port (server.xds_port or policy_server.port) -- there is no + // second listener the way ServerTLSConfig adds one for the REST API, + // since a gRPC server serves one credential type per port. + Enabled bool `koanf:"enabled"` + + // CertFile and KeyFile are the PEM-encoded server certificate and + // private key this xDS server presents to connecting clients. Required + // when Enabled. + CertFile string `koanf:"cert_file"` + KeyFile string `koanf:"key_file"` + + // ClientCAFile is a PEM bundle of CA certificates trusted to sign a + // connecting client's certificate (Envoy's or the policy-engine's). + // Required when Enabled -- this is what makes the handshake mutual + // rather than server-only. + ClientCAFile string `koanf:"client_ca_file"` + + // AllowedClientIdentities is an explicit allowlist of accepted peer + // certificate identities: a certificate's first SAN URI (e.g. a SPIFFE + // ID) if present, otherwise its Subject CommonName -- see + // pkg/tlsauth.PeerIdentity. A client certificate that chains to a + // trusted CA is not by itself authorization to reach this snapshot; at + // least one identity is required when Enabled, so this can't be + // silently left as a no-op allowlist (go-control-plane-xds-security.md + // directive 2). + AllowedClientIdentities []string `koanf:"allowed_client_identities"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the + // negotiated TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". + // Same vocabulary as ServerTLSConfig/router.downstream_tls for + // consistency within this file. + MinimumProtocolVersion string `koanf:"minimum_protocol_version"` + MaximumProtocolVersion string `koanf:"maximum_protocol_version"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // restricting which suites this server negotiates. Empty by default -- + // Go's own secure default set/order applies. Only affects TLS 1.2 and + // below; TLS 1.3 suite selection is not configurable in Go's crypto/tls. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups, + // most preferred first. Defaults to the hybrid post-quantum group + // ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) first, with classical + // fallbacks after -- this server is Go's own crypto/tls (1.23+ + // implements X25519MLKEM768 natively), so an Envoy/policy-engine peer + // that doesn't support the group simply falls back to a later classical + // entry in this same list. + EcdhCurves string `koanf:"ecdh_curves"` +} + +// ValidateXDSServerTLS validates an XDSServerTLSConfig block, a no-op when +// Enabled is false. fieldPrefix is the dotted config path used in error +// messages (e.g. "server.xds_tls" or "policy_server.tls"). +func ValidateXDSServerTLS(fieldPrefix string, cfg XDSServerTLSConfig) error { + if !cfg.Enabled { + return nil + } + if cfg.CertFile == "" { + return fmt.Errorf("%s.cert_file is required when %s.enabled", fieldPrefix, fieldPrefix) + } + if cfg.KeyFile == "" { + return fmt.Errorf("%s.key_file is required when %s.enabled", fieldPrefix, fieldPrefix) + } + if cfg.ClientCAFile == "" { + return fmt.Errorf("%s.client_ca_file is required when %s.enabled -- xDS requires mutual TLS, server-only TLS is not offered for this server", fieldPrefix, fieldPrefix) + } + if len(cfg.AllowedClientIdentities) == 0 { + return fmt.Errorf("%s.allowed_client_identities must list at least one accepted peer identity when %s.enabled", fieldPrefix, fieldPrefix) + } + for _, identity := range cfg.AllowedClientIdentities { + if strings.TrimSpace(identity) == "" { + return fmt.Errorf("%s.allowed_client_identities must not contain blank identities", fieldPrefix) + } + } + if err := ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return fmt.Errorf("%s: %w", fieldPrefix, err) + } + if _, err := ParseServerCiphers(cfg.Ciphers); err != nil { + return fmt.Errorf("%s.ciphers: %w", fieldPrefix, err) + } + if _, err := ParseServerEcdhCurves(cfg.EcdhCurves); err != nil { + return fmt.Errorf("%s.ecdh_curves: %w", fieldPrefix, err) + } + return nil +} + +// BuildXDSServerTLSConfig turns a validated XDSServerTLSConfig into a +// *tls.Config enforcing mutual TLS: the server's own certificate, plus a +// client CA pool used to require and verify a client certificate +// (tls.RequireAndVerifyClientCert). This only performs the TLS handshake -- +// checking the verified peer's identity against AllowedClientIdentities is +// a separate authorization step the xDS server's stream callbacks must +// still perform (see pkg/tlsauth.VerifyStreamPeer); a certificate chaining +// to a trusted CA is not by itself authorization to reach this snapshot. +// +// Callers should run ValidateXDSServerTLS first; this function re-validates +// version/cipher/curve fields defensively but does not check +// AllowedClientIdentities, which it never reads. +func BuildXDSServerTLSConfig(cfg XDSServerTLSConfig) (*tls.Config, error) { + if err := ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := ParseServerTLSVersion(cfg.MinimumProtocolVersion) + maxVersion, _ := ParseServerTLSVersion(cfg.MaximumProtocolVersion) + + cipherSuites, err := ParseServerCiphers(cfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := ParseServerEcdhCurves(cfg.EcdhCurves) + if err != nil { + return nil, err + } + + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("loading xDS server certificate: %w", err) + } + + caPEM, err := os.ReadFile(cfg.ClientCAFile) + if err != nil { + return nil, fmt.Errorf("reading xDS client CA file: %w", err) + } + clientCAs := x509.NewCertPool() + if !clientCAs.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no valid certificates found in xDS client CA file %q", cfg.ClientCAFile) + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientCAs: clientCAs, + ClientAuth: tls.RequireAndVerifyClientCert, + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil +} diff --git a/gateway/gateway-controller/pkg/config/xds_tls_test.go b/gateway/gateway-controller/pkg/config/xds_tls_test.go new file mode 100644 index 0000000000..11dbe1c986 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/xds_tls_test.go @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateXDSTestCA generates a self-signed CA and returns its cert/key +// (PEM-encoded) plus a helper that mints a leaf certificate signed by it. +func generateXDSTestCA(t *testing.T) (caCertPEM []byte, caKey *ecdsa.PrivateKey, caCert *x509.Certificate) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test xDS CA"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + return pemBytes, priv, cert +} + +// writeXDSLeafCert mints a leaf certificate signed by the given CA and +// writes its cert+key as PEM files under dir, returning their paths. +func writeXDSLeafCert(t *testing.T, dir, name string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey, isServer bool) (certPath, keyPath string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: name}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + if isServer { + template.DNSNames = []string{"localhost"} + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + } else { + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + } + + der, err := x509.CreateCertificate(rand.Reader, template, caCert, &priv.PublicKey, caKey) + require.NoError(t, err) + + certPath = filepath.Join(dir, name+".crt") + keyPath = filepath.Join(dir, name+".key") + + certOut, err := os.Create(certPath) + require.NoError(t, err) + defer certOut.Close() + require.NoError(t, pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der})) + + keyBytes, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + keyOut, err := os.Create(keyPath) + require.NoError(t, err) + defer keyOut.Close() + require.NoError(t, pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})) + + return certPath, keyPath +} + +func TestValidateXDSServerTLS(t *testing.T) { + validCfg := func() XDSServerTLSConfig { + return XDSServerTLSConfig{ + Enabled: true, + CertFile: "./certs/server.crt", + KeyFile: "./certs/server.key", + ClientCAFile: "./certs/ca.crt", + AllowedClientIdentities: []string{"spiffe://cluster.local/ns/gw/sa/envoy"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + tests := []struct { + name string + mutate func(*XDSServerTLSConfig) + wantErr bool + errContains string + }{ + {name: "valid config", mutate: func(*XDSServerTLSConfig) {}, wantErr: false}, + { + name: "disabled skips all checks", + mutate: func(c *XDSServerTLSConfig) { *c = XDSServerTLSConfig{Enabled: false} }, + wantErr: false, + }, + { + name: "missing cert file", + mutate: func(c *XDSServerTLSConfig) { c.CertFile = "" }, + wantErr: true, + errContains: "cert_file is required", + }, + { + name: "missing key file", + mutate: func(c *XDSServerTLSConfig) { c.KeyFile = "" }, + wantErr: true, + errContains: "key_file is required", + }, + { + name: "missing client CA file -- xDS has no server-only TLS mode", + mutate: func(c *XDSServerTLSConfig) { c.ClientCAFile = "" }, + wantErr: true, + errContains: "client_ca_file is required", + }, + { + name: "empty allowed client identities is fail-closed, not a no-op allowlist", + mutate: func(c *XDSServerTLSConfig) { c.AllowedClientIdentities = nil }, + wantErr: true, + errContains: "allowed_client_identities must list at least one", + }, + { + name: "blank allowed client identity is rejected", + mutate: func(c *XDSServerTLSConfig) { c.AllowedClientIdentities = []string{" "} }, + wantErr: true, + errContains: "allowed_client_identities must not contain blank identities", + }, + { + name: "bad protocol version", + mutate: func(c *XDSServerTLSConfig) { c.MinimumProtocolVersion = "TLS9_9" }, + wantErr: true, + errContains: "minimum_protocol_version", + }, + { + name: "unsupported cipher", + mutate: func(c *XDSServerTLSConfig) { c.Ciphers = "TLS_RSA_WITH_RC4_128_SHA" }, + wantErr: true, + errContains: "ciphers", + }, + { + name: "unsupported curve", + mutate: func(c *XDSServerTLSConfig) { c.EcdhCurves = "not-a-curve" }, + wantErr: true, + errContains: "ecdh_curves", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validCfg() + tt.mutate(&cfg) + err := ValidateXDSServerTLS("policy_server.tls", cfg) + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestBuildXDSServerTLSConfig(t *testing.T) { + dir := t.TempDir() + _, caKey, caCert := generateXDSTestCA(t) + caCertPath := filepath.Join(dir, "ca.crt") + require.NoError(t, os.WriteFile(caCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}), 0o600)) + serverCertPath, serverKeyPath := writeXDSLeafCert(t, dir, "server", caCert, caKey, true) + + t.Run("builds a working mTLS config", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"anything"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + tlsConfig, err := BuildXDSServerTLSConfig(cfg) + require.NoError(t, err) + require.NotNil(t, tlsConfig) + + assert.Len(t, tlsConfig.Certificates, 1) + assert.NotNil(t, tlsConfig.ClientCAs) + assert.Equal(t, tls.RequireAndVerifyClientCert, tlsConfig.ClientAuth) + assert.Equal(t, uint16(tls.VersionTLS12), tlsConfig.MinVersion) + assert.Equal(t, uint16(tls.VersionTLS13), tlsConfig.MaxVersion) + }) + + t.Run("PQC hybrid group opt-in is reflected in CurvePreferences", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"anything"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + } + tlsConfig, err := BuildXDSServerTLSConfig(cfg) + require.NoError(t, err) + require.NotEmpty(t, tlsConfig.CurvePreferences) + assert.Equal(t, tls.X25519MLKEM768, tlsConfig.CurvePreferences[0]) + }) + + t.Run("missing cert file surfaces a clear error", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: "/nonexistent/cert.pem", + KeyFile: "/nonexistent/key.pem", + ClientCAFile: caCertPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + }) + + t.Run("missing CA file surfaces a clear error", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: "/nonexistent/ca.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + }) + + t.Run("garbage CA file content is rejected", func(t *testing.T) { + badCAPath := filepath.Join(dir, "bad-ca.crt") + require.NoError(t, os.WriteFile(badCAPath, []byte("not a certificate"), 0o600)) + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: badCAPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no valid certificates") + }) +} + +// TestBuildXDSServerTLSConfig_PQCHandshakeNegotiation is a live TLS +// handshake (not just a config-shape assertion) proving BuildXDSServerTLSConfig's +// CurvePreferences is actually honored by crypto/tls's negotiation, and that a +// peer without PQC support still completes the handshake via classical +// fallback -- the exact interoperability guarantee post-quantum-cryptography.md +// requires ("must not hard-fail... when talking to a peer that only speaks +// classical algorithms"). +func TestBuildXDSServerTLSConfig_PQCHandshakeNegotiation(t *testing.T) { + dir := t.TempDir() + _, caKey, caCert := generateXDSTestCA(t) + caCertPath := filepath.Join(dir, "ca.crt") + require.NoError(t, os.WriteFile(caCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}), 0o600)) + serverCertPath, serverKeyPath := writeXDSLeafCert(t, dir, "server", caCert, caKey, true) + clientCertPath, clientKeyPath := writeXDSLeafCert(t, dir, "client", caCert, caKey, false) + + serverCfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"client"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + } + serverTLSConfig, err := BuildXDSServerTLSConfig(serverCfg) + require.NoError(t, err) + + clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + require.NoError(t, err) + clientCAPool := x509.NewCertPool() + clientCAPool.AddCert(caCert) + + dial := func(t *testing.T, clientCurves []tls.CurveID) tls.CurveID { + t.Helper() + ln, err := tls.Listen("tcp", "127.0.0.1:0", serverTLSConfig) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + negotiated := make(chan tls.CurveID, 1) + errCh := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + errCh <- err + return + } + defer func() { _ = conn.Close() }() + serverCtx, serverCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer serverCancel() + tlsConn := conn.(*tls.Conn) + if err := tlsConn.HandshakeContext(serverCtx); err != nil { + errCh <- err + return + } + negotiated <- tlsConn.ConnectionState().CurveID + errCh <- nil + }() + + clientTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{clientCert}, + RootCAs: clientCAPool, + ServerName: "localhost", + CurvePreferences: clientCurves, + } + clientCtx, clientCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer clientCancel() + dialer := &tls.Dialer{Config: clientTLSConfig} + conn, err := dialer.DialContext(clientCtx, "tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + require.NoError(t, <-errCh) + return <-negotiated + } + + t.Run("PQC-capable peer negotiates the hybrid group", func(t *testing.T) { + curve := dial(t, []tls.CurveID{tls.X25519MLKEM768, tls.X25519}) + assert.Equal(t, tls.X25519MLKEM768, curve) + }) + + t.Run("classical-only peer still completes the handshake", func(t *testing.T) { + curve := dial(t, []tls.CurveID{tls.CurveP256}) + assert.Equal(t, tls.CurveP256, curve) + }) +} diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index 3109bc6a68..0fdad5f67e 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -123,37 +123,45 @@ type WebhookSecretSnapshotRefresher interface { // Client manages the WebSocket connection to the control plane type Client struct { - config config.ControlPlaneConfig - logger *slog.Logger - state *ConnectionState - ctx context.Context - cancel context.CancelFunc - stopChan chan struct{} - wg sync.WaitGroup - writeMu sync.Mutex // serializes writes to the WebSocket connection - store *storage.ConfigStore - db storage.Storage - snapshotManager *xds.SnapshotManager - parser *config.Parser - validator config.Validator - deploymentService *utils.APIDeploymentService - apiUtilsService *utils.APIUtilsService - apiKeyService *utils.APIKeyService - llmDeploymentService *utils.LLMDeploymentService - mcpDeploymentService *utils.MCPDeploymentService - apiKeyXDSManager utils.XDSManager - apiKeyStore *storage.APIKeyStore - routerConfig *config.RouterConfig - policyManager *policyxds.PolicyManager - systemConfig *config.Config - policyDefinitions map[string]models.PolicyDefinition - subscriptionSnapshotUpdater utils.SubscriptionSnapshotUpdater - subscriptionResourceService *utils.SubscriptionResourceService - eventHub eventhub.EventHub - gatewayID string - gatewayPath string // cached gateway path from well-known discovery - syncOnce sync.Once // ensures deployment sync runs only on first connect - isFirstConnect atomic.Bool // true on first connect, flipped to false after + config config.ControlPlaneConfig + logger *slog.Logger + state *ConnectionState + ctx context.Context + cancel context.CancelFunc + stopChan chan struct{} + wg sync.WaitGroup + writeMu sync.Mutex // serializes writes to the WebSocket connection + store *storage.ConfigStore + db storage.Storage + snapshotManager *xds.SnapshotManager + parser *config.Parser + validator config.Validator + deploymentService *utils.APIDeploymentService + apiUtilsService *utils.APIUtilsService + apiKeyService *utils.APIKeyService + llmDeploymentService *utils.LLMDeploymentService + mcpDeploymentService *utils.MCPDeploymentService + apiKeyXDSManager utils.XDSManager + apiKeyStore *storage.APIKeyStore + routerConfig *config.RouterConfig + policyManager *policyxds.PolicyManager + systemConfig *config.Config + policyDefinitions map[string]models.PolicyDefinition + subscriptionSnapshotUpdater utils.SubscriptionSnapshotUpdater + subscriptionResourceService *utils.SubscriptionResourceService + eventHub eventhub.EventHub + gatewayID string + gatewayPath string // cached gateway path from well-known discovery + syncOnce sync.Once // ensures deployment sync runs only on first connect + isFirstConnect atomic.Bool // true on first connect, flipped to false after + + // httpClient is the single shared outbound *http.Client for this entire process (built + // once in cmd/controller/main.go and injected here), used for every plain (non-WebSocket) + // REST call this Client makes to the control plane host — well-known gateway-path + // discovery, gateway manifest push, platform-API calls (via apiUtilsService), and on-prem + // APIM calls. Per-operation timeout budgets (5s well-known, 30s manifest, etc.) are + // enforced via context.WithTimeout at each call site rather than a client-level Timeout. + httpClient *http.Client webhookSecretStore *webhooksecret.WebhookSecretStore webhookSecretSnapshotManager WebhookSecretSnapshotRefresher secretSyncer secretSyncer @@ -192,6 +200,7 @@ func NewClient( secretResolver funcs.SecretResolver, webhookSecretStore *webhooksecret.WebhookSecretStore, webhookSecretSnapshotManager WebhookSecretSnapshotRefresher, + httpClient *http.Client, ) *Client { if db == nil { panic("control plane client requires non-nil storage") @@ -209,7 +218,7 @@ func NewClient( ctx, cancel := context.WithCancel(context.Background()) - deploymentService := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerConfig, eventHubInstance, gatewayID, secretResolver) + deploymentService := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerConfig, eventHubInstance, gatewayID, secretResolver, httpClient) apiKeyService := utils.NewAPIKeyService(store, db, apiKeyXDSManager, apiKeyConfig, eventHubInstance, gatewayID) subscriptionResourceService := utils.NewSubscriptionResourceService(db, subSnapshotManager, eventHubInstance, gatewayID) @@ -235,6 +244,7 @@ func NewClient( gatewayID: gatewayID, webhookSecretStore: webhookSecretStore, webhookSecretSnapshotManager: webhookSecretSnapshotManager, + httpClient: httpClient, state: &ConnectionState{ Current: Disconnected, Conn: nil, @@ -289,7 +299,7 @@ func NewClient( Token: cfg.Token, InsecureSkipVerify: cfg.InsecureSkipVerify, Timeout: 30 * time.Second, - }, logger) + }, httpClient, logger) // Set OAuth2 credentials for on-prem APIM (for API import operations) // Construct TokenURL from the controlplane host @@ -679,21 +689,15 @@ func (c *Client) isOnPrem() bool { func (c *Client) discoverGatewayPath() (string, error) { wellKnownURL := fmt.Sprintf("https://%s/internal/gateway/.well-known", c.config.Host) - httpClient := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: c.config.InsecureSkipVerify, - }, - }, - } + ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second) + defer cancel() - req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, wellKnownURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil) if err != nil { return "", fmt.Errorf("failed to create well-known request: %w", err) } - resp, err := httpClient.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to call well-known endpoint: %w", err) } @@ -4038,23 +4042,17 @@ func (c *Client) pushGatewayManifest(gatewayID string, policies []models.PolicyD return fmt.Errorf("failed to marshal manifest payload: %w", err) } - httpClient := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: c.config.InsecureSkipVerify, - }, - }, - } + ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) + defer cancel() - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create manifest request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("api-key", c.config.Token) - resp, err := httpClient.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send gateway manifest: %w", err) } diff --git a/gateway/gateway-controller/pkg/controlplane/client_integration_test.go b/gateway/gateway-controller/pkg/controlplane/client_integration_test.go index ca22826252..bf867357df 100644 --- a/gateway/gateway-controller/pkg/controlplane/client_integration_test.go +++ b/gateway/gateway-controller/pkg/controlplane/client_integration_test.go @@ -20,6 +20,7 @@ package controlplane import ( "encoding/json" + "fmt" "log/slog" "net" "net/http" @@ -33,12 +34,31 @@ import ( "github.com/wso2/api-platform/common/eventhub" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" + "github.com/wso2/api-platform/httpkit/httpclient" ) var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } +// testHTTPClient builds a plain outbound *http.Client for tests via httpkit's +// secure-by-default builder. Production code injects one single shared client built in +// cmd/controller/main.go, with TLS.InsecureSkipVerify mirroring +// Controller.ControlPlane.InsecureSkipVerify (see main.go's sharedHTTPClientCfg wiring); +// tests here build their own throwaway instance the same way, since several tests point +// the Client under test at an httptest.NewTLSServer with a self-signed certificate and +// need insecureSkipVerify=true to avoid failing certificate verification. +func testHTTPClient(insecureSkipVerify bool) *http.Client { + cfg := httpclient.DefaultConfig() + cfg.TLS.InsecureSkipVerify = insecureSkipVerify // #nosec G402 -- test-only, mirrors the config under test. + cfg.TLS.InsecureSkipVerifyAcknowledged = insecureSkipVerify // required double-gate; mirrors InsecureSkipVerify. + client, err := httpclient.New(cfg) + if err != nil { + panic(fmt.Sprintf("test HTTP client: unreachable construction error for a fixed default config: %v", err)) + } + return client +} + type integrationTestEventHub struct{} func (m *integrationTestEventHub) Initialize() error { return nil } @@ -144,7 +164,7 @@ func createIntegrationTestClientWithConfig(t *testing.T, cfg config.ControlPlane APIKey: *apiKeyConfig, } - client := NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil) + client := NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil, testHTTPClient(cfg.InsecureSkipVerify)) require.NotNil(t, client.eventHub) require.Equal(t, "test-gateway", client.gatewayID) return client diff --git a/gateway/gateway-controller/pkg/controlplane/controlplane_test.go b/gateway/gateway-controller/pkg/controlplane/controlplane_test.go index c3c9a1b72b..0b7542c5e4 100644 --- a/gateway/gateway-controller/pkg/controlplane/controlplane_test.go +++ b/gateway/gateway-controller/pkg/controlplane/controlplane_test.go @@ -222,7 +222,7 @@ func createTestClientWithConfig(t *testing.T, cfg config.ControlPlaneConfig) *Cl APIKey: *apiKeyConfig, } - return NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil) + return NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil, testHTTPClient(cfg.InsecureSkipVerify)) } func createTestClientWithHost(t *testing.T, host string) *Client { diff --git a/gateway/gateway-controller/pkg/controlplane/llm_deletion_test.go b/gateway/gateway-controller/pkg/controlplane/llm_deletion_test.go index 177f87783a..24b0efd5a2 100644 --- a/gateway/gateway-controller/pkg/controlplane/llm_deletion_test.go +++ b/gateway/gateway-controller/pkg/controlplane/llm_deletion_test.go @@ -44,7 +44,7 @@ func createLLMDeletionTestClient() (*Client, *storage.ConfigStore, *mockStorageF hub := &mockControlPlaneEventHub{} routerConfig := &config.RouterConfig{ListenerPort: 8080} - apiDeploymentService := utils.NewAPIDeploymentService(store, db, nil, nil, routerConfig, hub, "test-gateway", nil) + apiDeploymentService := utils.NewAPIDeploymentService(store, db, nil, nil, routerConfig, hub, "test-gateway", nil, nil) llmService := utils.NewLLMDeploymentService(store, db, nil, nil, nil, apiDeploymentService, routerConfig, nil, nil) client := &Client{ diff --git a/gateway/gateway-controller/pkg/controlplane/sync.go b/gateway/gateway-controller/pkg/controlplane/sync.go index 32147706a7..3d2da9c1ea 100644 --- a/gateway/gateway-controller/pkg/controlplane/sync.go +++ b/gateway/gateway-controller/pkg/controlplane/sync.go @@ -751,7 +751,7 @@ func (c *Client) SyncArtifactsToOnPremAPIM(apimConfig *utils.APIMConfig) error { } for attempt := 1; attempt <= maxRetries; attempt++ { - lastErr = utils.UndeployRevisionFromAPIM(*apimConfig, apimAPIID, revisionID, c.logger) + lastErr = utils.UndeployRevisionFromAPIM(*apimConfig, c.httpClient, apimAPIID, revisionID, c.logger) if lastErr == nil { break } @@ -801,7 +801,7 @@ func (c *Client) SyncArtifactsToOnPremAPIM(apimConfig *utils.APIMConfig) error { // CPSyncInfo contains {"id": "", "revision": "..."} from the last successful sync. swaggerOverride := "" if apimAPIID, _ := parseCPSyncInfo(api.CPSyncInfo); apimAPIID != "" { - swagger, err := utils.FetchSwaggerFromAPIM(*apimConfig, apimAPIID, c.logger) + swagger, err := utils.FetchSwaggerFromAPIM(*apimConfig, c.httpClient, apimAPIID, c.logger) if err != nil { c.logger.Warn("Bottom-up sync: failed to fetch swagger from APIM, falling back to local generation", slog.String("uuid", api.UUID), @@ -838,7 +838,7 @@ func (c *Client) SyncArtifactsToOnPremAPIM(apimConfig *utils.APIMConfig) error { currentBuffer := bytes.NewBuffer(zipBytes) importResp, lastErr = utils.ImportAPIToAPIMWithConfig( - *apimConfig, c.logger, api.UUID+".zip", currentBuffer, + *apimConfig, c.httpClient, c.logger, api.UUID+".zip", currentBuffer, ) if lastErr == nil { break diff --git a/gateway/gateway-controller/pkg/policyxds/server.go b/gateway/gateway-controller/pkg/policyxds/server.go index 36e05842ec..f13c0ca00e 100644 --- a/gateway/gateway-controller/pkg/policyxds/server.go +++ b/gateway/gateway-controller/pkg/policyxds/server.go @@ -20,6 +20,7 @@ package policyxds import ( "context" + "crypto/tls" "fmt" "log/slog" "net" @@ -30,6 +31,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/subscriptionxds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/tlsauth" core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" discoverygrpc "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" @@ -39,6 +41,15 @@ import ( "google.golang.org/grpc/keepalive" ) +// Bounds on the policy xDS gRPC server (go-network-service-hardening.md +// directive 2 / go-control-plane-xds-security.md directive 5) -- unbounded +// defaults let one client (a misbehaving/compromised policy-engine) exhaust +// memory or the stream-slot budget every other connection depends on. +const ( + policyXDSMaxMessageSize = 16 * 1024 * 1024 + policyXDSMaxConcurrentStreams = 1000 +) + // WebhookSecretCacheProvider is the extension point through which an external // event-gateway-controller binary supplies the xDS cache backing webhook-secret // (HMAC) resources. Core never implements this interface itself; it is only @@ -58,28 +69,34 @@ type Server struct { subscriptionSnapshotMgr *subscriptionxds.SnapshotManager webhookSecretSnapshotMgr WebhookSecretCacheProvider port int - tlsConfig *TLSConfig + mtls *serverMTLS onFirstConnect chan struct{} logger *slog.Logger } -// TLSConfig holds TLS configuration for the server -type TLSConfig struct { - Enabled bool - CertFile string - KeyFile string +// serverMTLS holds the resolved mutual-TLS state for the policy xDS server. +type serverMTLS struct { + tlsConfig *tls.Config + allowedIdentities map[string]bool } // ServerOption is a functional option for configuring the Server type ServerOption func(*Server) -// WithTLS enables TLS with the provided certificate and key files -func WithTLS(certFile, keyFile string) ServerOption { +// WithMTLS enables mutual TLS on the policy xDS gRPC server (serving the +// policy-engine). tlsConfig must come from config.BuildXDSServerTLSConfig, +// which already sets ClientAuth: tls.RequireAndVerifyClientCert -- +// server-only TLS is not offered here because this channel carries +// per-tenant API-key hashes, subscription state, and full policy chains, +// so authenticating only the server side is not enough +// (go-control-plane-xds-security.md directive 2). allowedIdentities +// restricts accepted streams to peers whose certificate identity +// (tlsauth.PeerIdentity) is in the list. +func WithMTLS(tlsConfig *tls.Config, allowedIdentities []string) ServerOption { return func(s *Server) { - s.tlsConfig = &TLSConfig{ - Enabled: true, - CertFile: certFile, - KeyFile: keyFile, + s.mtls = &serverMTLS{ + tlsConfig: tlsConfig, + allowedIdentities: tlsauth.AllowedSet(allowedIdentities), } } } @@ -101,7 +118,6 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP webhookSecretSnapshotMgr: webhookSecretSnapshotMgr, port: port, logger: logger, - tlsConfig: &TLSConfig{Enabled: false}, } // Apply options @@ -119,19 +135,17 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP MinTime: 5 * time.Second, PermitWithoutStream: true, }), + grpc.MaxRecvMsgSize(policyXDSMaxMessageSize), + grpc.MaxSendMsgSize(policyXDSMaxMessageSize), + grpc.MaxConcurrentStreams(policyXDSMaxConcurrentStreams), } - // Add TLS credentials if enabled - if s.tlsConfig.Enabled { - creds, err := credentials.NewServerTLSFromFile(s.tlsConfig.CertFile, s.tlsConfig.KeyFile) - if err != nil { - logger.Error("Failed to load TLS credentials", slog.Any("error", err)) - panic(err) - } - grpcOpts = append(grpcOpts, grpc.Creds(creds)) - logger.Info("TLS enabled for Policy xDS server", - slog.String("cert_file", s.tlsConfig.CertFile), - slog.String("key_file", s.tlsConfig.KeyFile)) + // Add mTLS credentials if enabled + var allowedIdentities map[string]bool + if s.mtls != nil { + grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(s.mtls.tlsConfig))) + allowedIdentities = s.mtls.allowedIdentities + logger.Info("mTLS enabled for Policy xDS server") } grpcServer := grpc.NewServer(grpcOpts...) @@ -150,10 +164,11 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP combinedCache := NewCombinedCache(policyCache, apiKeyCache, lazyResourceCache, subscriptionCache, routeConfigCache, eventChannelCache, webhookSecretCache, logger) callbacks := &serverCallbacks{ - logger: logger, - activeStreams: make(map[int64]bool), - onFirstConnect: s.onFirstConnect, - pendingNonces: make(map[int64]string), + logger: logger, + activeStreams: make(map[int64]bool), + onFirstConnect: s.onFirstConnect, + pendingNonces: make(map[int64]string), + allowedIdentities: allowedIdentities, } xdsServer := server.NewServer(context.Background(), combinedCache, callbacks) @@ -174,8 +189,8 @@ func (s *Server) Start() error { } protocol := "insecure" - if s.tlsConfig.Enabled { - protocol = "TLS" + if s.mtls != nil { + protocol = "mTLS" } s.logger.Info("Starting Policy xDS server", slog.Int("port", s.port), @@ -196,16 +211,24 @@ func (s *Server) Stop() { // serverCallbacks implements xDS server callbacks for logging and debugging type serverCallbacks struct { - logger *slog.Logger - activeStreams map[int64]bool - activeStreamsMu sync.Mutex - onFirstConnect chan struct{} - firstConnectOnce sync.Once - pendingNonces map[int64]string // stream_id -> last sent nonce + logger *slog.Logger + activeStreams map[int64]bool + activeStreamsMu sync.Mutex + onFirstConnect chan struct{} + firstConnectOnce sync.Once + pendingNonces map[int64]string // stream_id -> last sent nonce + allowedIdentities map[string]bool // nil when mTLS is not configured -- no identity check performed } // OnStreamOpen is called when a new stream is opened func (cb *serverCallbacks) OnStreamOpen(ctx context.Context, streamID int64, typeURL string) error { + if cb.allowedIdentities != nil { + if err := tlsauth.VerifyStreamPeer(ctx, cb.allowedIdentities); err != nil { + cb.logger.Warn("Policy xDS stream rejected: peer identity not authorized", + slog.Int64("stream_id", streamID), slog.Any("error", err)) + return err + } + } cb.logger.Info("Policy xDS stream opened", slog.Int64("stream_id", streamID), slog.String("type_url", typeURL)) diff --git a/gateway/gateway-controller/pkg/policyxds/server_test.go b/gateway/gateway-controller/pkg/policyxds/server_test.go index fc129805f4..ad6aad2dd9 100644 --- a/gateway/gateway-controller/pkg/policyxds/server_test.go +++ b/gateway/gateway-controller/pkg/policyxds/server_test.go @@ -20,6 +20,7 @@ package policyxds import ( "context" + "crypto/tls" "io" "log/slog" "testing" @@ -27,6 +28,7 @@ import ( core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" discoverygrpc "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/anypb" ) @@ -164,35 +166,28 @@ func TestServerCallbacks_OnStreamDeltaResponse(t *testing.T) { cb.OnStreamDeltaResponse(789, req, resp) } -func TestWithTLS(t *testing.T) { - t.Run("enables TLS configuration", func(t *testing.T) { +func TestWithMTLS(t *testing.T) { + t.Run("enables mTLS configuration", func(t *testing.T) { s := &Server{} - opt := WithTLS("/path/to/cert.pem", "/path/to/key.pem") + tlsConfig := &tls.Config{} + opt := WithMTLS(tlsConfig, []string{"spiffe://cluster.local/ns/gw/sa/policy-engine"}) opt(s) - assert.NotNil(t, s.tlsConfig) - assert.True(t, s.tlsConfig.Enabled) - assert.Equal(t, "/path/to/cert.pem", s.tlsConfig.CertFile) - assert.Equal(t, "/path/to/key.pem", s.tlsConfig.KeyFile) + require.NotNil(t, s.mtls) + assert.Same(t, tlsConfig, s.mtls.tlsConfig) + assert.True(t, s.mtls.allowedIdentities["spiffe://cluster.local/ns/gw/sa/policy-engine"]) }) } -func TestTLSConfig(t *testing.T) { - t.Run("default values", func(t *testing.T) { - config := &TLSConfig{} - assert.False(t, config.Enabled) - assert.Empty(t, config.CertFile) - assert.Empty(t, config.KeyFile) - }) +func TestServerCallbacks_OnStreamOpen_RejectsUnauthorizedPeer(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + cb := &serverCallbacks{ + logger: logger, + allowedIdentities: map[string]bool{"spiffe://cluster.local/ns/gw/sa/policy-engine": true}, + } - t.Run("with values", func(t *testing.T) { - config := &TLSConfig{ - Enabled: true, - CertFile: "cert.pem", - KeyFile: "key.pem", - } - assert.True(t, config.Enabled) - assert.Equal(t, "cert.pem", config.CertFile) - assert.Equal(t, "key.pem", config.KeyFile) - }) + // No peer/TLS info in a bare background context -- must be rejected, + // not silently allowed through, once an identity allowlist is configured. + err := cb.OnStreamOpen(context.Background(), 999, "test-type-url") + assert.Error(t, err) } diff --git a/gateway/gateway-controller/pkg/tlsauth/peer_identity.go b/gateway/gateway-controller/pkg/tlsauth/peer_identity.go new file mode 100644 index 0000000000..b5fcd18820 --- /dev/null +++ b/gateway/gateway-controller/pkg/tlsauth/peer_identity.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package tlsauth provides the peer-identity authorization check shared by +// every xDS gRPC server in gateway-controller (pkg/xds, pkg/policyxds). +// Mutual TLS alone proves a connecting client's certificate chains to a +// trusted CA; it does not prove that client is entitled to this particular +// snapshot. go-control-plane-xds-security.md directive 2 requires an +// explicit accept/reject decision against a known-identity allowlist on +// every stream, in addition to the TLS handshake itself. +package tlsauth + +import ( + "context" + "crypto/x509" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +// PeerIdentity returns the identity a verified client certificate presents: +// the first SAN URI (e.g. a SPIFFE ID) if present, otherwise the +// certificate's Subject CommonName. +func PeerIdentity(cert *x509.Certificate) string { + if len(cert.URIs) > 0 { + return cert.URIs[0].String() + } + return cert.Subject.CommonName +} + +// AllowedSet converts a config allowlist slice into the map VerifyStreamPeer +// expects. +func AllowedSet(identities []string) map[string]bool { + set := make(map[string]bool, len(identities)) + for _, id := range identities { + set[id] = true + } + return set +} + +// VerifyStreamPeer checks that a streaming RPC's authenticated context +// carries a client certificate whose identity (see PeerIdentity) is in +// allowed. Returns a gRPC status error suitable for returning directly from +// an xDS server.Callbacks.OnStreamOpen implementation; any client that +// clears the mTLS handshake but isn't in allowed is rejected here, not +// merely logged. +func VerifyStreamPeer(ctx context.Context, allowed map[string]bool) error { + p, ok := peer.FromContext(ctx) + if !ok { + return status.Error(codes.Unauthenticated, "no peer information") + } + tlsInfo, isTLS := p.AuthInfo.(credentials.TLSInfo) + if !isTLS || len(tlsInfo.State.PeerCertificates) == 0 { + return status.Error(codes.Unauthenticated, "no client certificate presented") + } + identity := PeerIdentity(tlsInfo.State.PeerCertificates[0]) + if !allowed[identity] { + return status.Error(codes.PermissionDenied, "peer identity not authorized for this xDS snapshot") + } + return nil +} diff --git a/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go b/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go new file mode 100644 index 0000000000..9f37fd2014 --- /dev/null +++ b/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package tlsauth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +func generateTestCert(t *testing.T, cn string, uris []*url.URL) *x509.Certificate { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + URIs: uris, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func TestPeerIdentity(t *testing.T) { + t.Run("prefers the first SAN URI over CommonName", func(t *testing.T) { + spiffeID, err := url.Parse("spiffe://cluster.local/ns/gw/sa/envoy") + require.NoError(t, err) + cert := generateTestCert(t, "envoy-router", []*url.URL{spiffeID}) + assert.Equal(t, "spiffe://cluster.local/ns/gw/sa/envoy", PeerIdentity(cert)) + }) + + t.Run("falls back to CommonName when there is no SAN URI", func(t *testing.T) { + cert := generateTestCert(t, "policy-engine", nil) + assert.Equal(t, "policy-engine", PeerIdentity(cert)) + }) +} + +func TestAllowedSet(t *testing.T) { + set := AllowedSet([]string{"a", "b", "a"}) + assert.True(t, set["a"]) + assert.True(t, set["b"]) + assert.False(t, set["c"]) + assert.Len(t, set, 2) + + assert.Empty(t, AllowedSet(nil)) +} + +func TestVerifyStreamPeer(t *testing.T) { + t.Run("no peer info in context is unauthenticated", func(t *testing.T) { + err := VerifyStreamPeer(context.Background(), AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("peer info without TLS auth info is unauthenticated", func(t *testing.T) { + p := &peer.Peer{Addr: &net.TCPAddr{}} + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("TLS peer with no client certificate is unauthenticated", func(t *testing.T) { + p := &peer.Peer{ + Addr: &net.TCPAddr{}, + AuthInfo: credentials.TLSInfo{}, + } + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("allowed identity passes", func(t *testing.T) { + cert := generateTestCert(t, "envoy-router", nil) + p := makeTLSPeer(cert) + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"envoy-router"})) + assert.NoError(t, err) + }) + + t.Run("unlisted identity is rejected even with a valid client cert", func(t *testing.T) { + cert := generateTestCert(t, "unknown-caller", nil) + p := makeTLSPeer(cert) + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"envoy-router"})) + assert.Error(t, err) + }) +} + +func makeTLSPeer(cert *x509.Certificate) *peer.Peer { + return &peer.Peer{ + Addr: &net.TCPAddr{}, + AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{PeerCertificates: []*x509.Certificate{cert}}, + }, + } +} diff --git a/gateway/gateway-controller/pkg/utils/api_deployment.go b/gateway/gateway-controller/pkg/utils/api_deployment.go index ce638ddf10..80fea72cc3 100644 --- a/gateway/gateway-controller/pkg/utils/api_deployment.go +++ b/gateway/gateway-controller/pkg/utils/api_deployment.go @@ -108,7 +108,10 @@ func (s *APIDeploymentService) validateArtifactConflicts(kind, currentID, displa return nil } -// NewAPIDeploymentService creates a new API deployment service +// NewAPIDeploymentService creates a new API deployment service. httpClient is the single +// shared outbound *http.Client for this process (built once in cmd/controller/main.go and +// injected by every caller), used for RegisterTopicWithHub/UnregisterTopicWithHub-style calls +// that need one, rather than this service building its own. func NewAPIDeploymentService( store *storage.ConfigStore, db storage.Storage, @@ -118,6 +121,7 @@ func NewAPIDeploymentService( eventHub eventhub.EventHub, gatewayID string, secretResolver funcs.SecretResolver, + httpClient *http.Client, ) *APIDeploymentService { if db == nil { panic("APIDeploymentService requires non-nil storage") @@ -130,7 +134,7 @@ func NewAPIDeploymentService( snapshotManager: snapshotManager, parser: config.NewParser(), validator: validator, - httpClient: &http.Client{Timeout: 10 * time.Second}, + httpClient: httpClient, routerConfig: routerConfig, eventHub: eventHub, gatewayID: trimmedGatewayID, diff --git a/gateway/gateway-controller/pkg/utils/api_utils.go b/gateway/gateway-controller/pkg/utils/api_utils.go index 1ea2c1b9d0..698a9555b1 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils.go +++ b/gateway/gateway-controller/pkg/utils/api_utils.go @@ -23,7 +23,7 @@ import ( "archive/zip" "bytes" "compress/gzip" - "crypto/tls" + "context" "encoding/json" "fmt" "io" @@ -73,8 +73,10 @@ type APIUtilsService struct { TokenURL string // Token endpoint URL } -// NewAPIUtilsService creates a new API utilities service -func NewAPIUtilsService(config PlatformAPIConfig, logger *slog.Logger) *APIUtilsService { +// NewAPIUtilsService creates a new API utilities service. httpClient is the single shared +// outbound *http.Client for this process (built once in cmd/controller/main.go and injected +// by every caller), reused here rather than each service building its own. +func NewAPIUtilsService(config PlatformAPIConfig, httpClient *http.Client, logger *slog.Logger) *APIUtilsService { // Set default timeout if not provided if config.Timeout == 0 { config.Timeout = 30 * time.Second @@ -86,27 +88,10 @@ func NewAPIUtilsService(config PlatformAPIConfig, logger *slog.Logger) *APIUtils logger.Warn("TLS certificate verification disabled for API utils (insecure_skip_verify=true)") } - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: config.InsecureSkipVerify, - MinVersion: tls.VersionTLS12, - }, - // Connection pool tuning - MaxIdleConns: 20, - MaxIdleConnsPerHost: 5, - MaxConnsPerHost: 10, - IdleConnTimeout: 30 * time.Second, - } - - client := &http.Client{ - Timeout: config.Timeout, - Transport: transport, - } - return &APIUtilsService{ config: config, logger: logger, - client: client, + client: httpClient, } } @@ -139,7 +124,9 @@ func (s *APIUtilsService) FetchAPIDefinition(apiID string) ([]byte, error) { ) // Create request - req, err := http.NewRequest("GET", apiURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -186,7 +173,9 @@ func (s *APIUtilsService) FetchLLMProviderDefinition(providerID string) ([]byte, ) // Create request - req, err := http.NewRequest("GET", providerURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", providerURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -233,7 +222,9 @@ func (s *APIUtilsService) FetchLLMProxyDefinition(proxyID string) ([]byte, error ) // Create request - req, err := http.NewRequest("GET", proxyURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", proxyURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -278,23 +269,16 @@ func (s *APIUtilsService) FetchSubscriptionsForAPI(apiID string) ([]models.Subsc slog.String("url", subURL), ) - client := &http.Client{ - Timeout: s.config.Timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: s.config.InsecureSkipVerify, - }, - }, - } - - req, err := http.NewRequest("GET", subURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", subURL, nil) if err != nil { return nil, fmt.Errorf("failed to create subscriptions request: %w", err) } req.Header.Add("api-key", s.config.Token) req.Header.Add("Accept", "application/json") - resp, err := client.Do(req) + resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch subscriptions: %w", err) } @@ -372,7 +356,9 @@ func (s *APIUtilsService) FetchAPIKeysByKind(artifactKind, issuer string) ([]mod slog.Bool("issuer_filtered", issuer != ""), ) - req, err := http.NewRequest("GET", endpoint, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create API keys request: %w", err) } @@ -450,23 +436,16 @@ func (s *APIUtilsService) FetchSubscriptionPlans() ([]models.SubscriptionPlan, e s.logger.Info("Fetching subscription plans", slog.String("url", planURL)) - client := &http.Client{ - Timeout: s.config.Timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: s.config.InsecureSkipVerify, - }, - }, - } - - req, err := http.NewRequest("GET", planURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", planURL, nil) if err != nil { return nil, fmt.Errorf("failed to create subscription plans request: %w", err) } req.Header.Add("api-key", s.config.Token) req.Header.Add("Accept", "application/json") - resp, err := client.Do(req) + resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch subscription plans: %w", err) } @@ -612,7 +591,9 @@ func (s *APIUtilsService) FetchMCPProxyDefinition(proxyID string) ([]byte, error ) // Create request - req, err := http.NewRequest("GET", proxyURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", proxyURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -663,7 +644,9 @@ func (s *APIUtilsService) FetchResourceZip(resourcePath, resourceLabel string) ( slog.String("url", url), ) - req, err := http.NewRequest("GET", url, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -703,7 +686,9 @@ func (s *APIUtilsService) FetchResourceZip(resourcePath, resourceLabel string) ( func (s *APIUtilsService) FetchResourceJSON(resourcePath, resourceLabel string, out any) error { url := s.getBaseURL() + resourcePath - req, err := http.NewRequest("GET", url, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } @@ -768,7 +753,9 @@ func (s *APIUtilsService) FetchControlPlaneDeployments(since *time.Time) ([]mode slog.String("url", deploymentsURL), ) - req, err := http.NewRequest("GET", deploymentsURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", deploymentsURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -818,7 +805,9 @@ func (s *APIUtilsService) BatchFetchDeployments(deploymentIDs []string) ([]byte, return nil, fmt.Errorf("failed to marshal batch fetch request: %w", err) } - req, err := http.NewRequest("POST", batchURL, bytes.NewBuffer(jsonData)) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", batchURL, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -1155,7 +1144,9 @@ func (s *APIUtilsService) PushArtifacts(artifacts []*models.StoredConfig) (*Impo } importURL := s.getBaseURL() + "/artifacts/import-gateway-artifacts" - req, err := http.NewRequest("POST", importURL, body) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", importURL, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -1284,7 +1275,9 @@ func (s *APIUtilsService) CheckArtifactsExist(artifactIDs []string) ([]string, e return nil, fmt.Errorf("failed to marshal artifact existence request: %w", err) } - req, err := http.NewRequest("POST", existsURL, bytes.NewReader(jsonData)) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", existsURL, bytes.NewReader(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -1373,7 +1366,9 @@ func (s *APIUtilsService) FetchPlatformSecrets(updatedAfter *time.Time, includeV slog.Bool("includeValues", includeValues), ) - req, err := http.NewRequest(http.MethodGet, secretsURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, secretsURL, nil) if err != nil { return nil, fmt.Errorf("failed to create secrets request: %w", err) } @@ -1410,7 +1405,9 @@ func (s *APIUtilsService) FetchPlatformSecretValue(secretHandle string) (string, slog.String("url", valueURL), ) - req, err := http.NewRequest(http.MethodGet, valueURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, valueURL, nil) if err != nil { return "", fmt.Errorf("failed to create secret value request: %w", err) } diff --git a/gateway/gateway-controller/pkg/utils/api_utils_test.go b/gateway/gateway-controller/pkg/utils/api_utils_test.go index 0b6573b046..e7a949f09c 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils_test.go +++ b/gateway/gateway-controller/pkg/utils/api_utils_test.go @@ -66,7 +66,7 @@ func TestNewAPIUtilsService(t *testing.T) { BaseURL: "http://localhost:8080", Token: "test-token", } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) assert.NotNil(t, svc) assert.Equal(t, 30*time.Second, svc.config.Timeout) }) @@ -77,7 +77,7 @@ func TestNewAPIUtilsService(t *testing.T) { Token: "test-token", Timeout: 60 * time.Second, } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) assert.NotNil(t, svc) assert.Equal(t, 60*time.Second, svc.config.Timeout) }) @@ -101,7 +101,7 @@ func TestAPIUtilsService_FetchAPIDefinition(t *testing.T) { BaseURL: server.URL, Token: "test-token", } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchAPIDefinition("test-api-123") assert.NoError(t, err) @@ -119,7 +119,7 @@ func TestAPIUtilsService_FetchAPIDefinition(t *testing.T) { BaseURL: server.URL, Token: "test-token", } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchAPIDefinition("nonexistent") assert.Error(t, err) @@ -133,7 +133,7 @@ func TestAPIUtilsService_FetchAPIDefinition(t *testing.T) { Token: "test-token", Timeout: 1 * time.Second, } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchAPIDefinition("0000-test-api-0000-000000000000") assert.Error(t, err) @@ -144,7 +144,7 @@ func TestAPIUtilsService_FetchAPIDefinition(t *testing.T) { func TestAPIUtilsService_ExtractYAMLFromZip(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := PlatformAPIConfig{BaseURL: "http://localhost"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) t.Run("Extract YAML file", func(t *testing.T) { // Create a zip with a YAML file @@ -190,7 +190,7 @@ func TestAPIUtilsService_ExtractYAMLFromZip(t *testing.T) { func TestAPIUtilsService_SaveAPIDefinition(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := PlatformAPIConfig{BaseURL: "http://localhost"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) // Create a temp directory for testing tmpDir, err := os.MkdirTemp("", "api-test-*") @@ -306,7 +306,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { })) defer server.Close() - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) cpArtifactID, err := svc.PushArtifact("0000-test-api-0000-000000000000", createTestStoredConfig("RestApi"), "") assert.NoError(t, err) // The CP-minted artifact UUID from the per-dpid result is returned to the caller. @@ -329,7 +329,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { })) defer server.Close() - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact("0000-test-api-0000-000000000000", createTestStoredConfig("LlmProvider"), "") assert.NoError(t, err) }) @@ -352,7 +352,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { cfg := createTestStoredConfig(models.KindLlmProviderTemplate) cfg.DeployedAt = nil // templates never set DeployedAt - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact(cfg.UUID, cfg, "") require.NoError(t, err) @@ -379,7 +379,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { deployedAt := time.Date(2026, 5, 6, 7, 8, 9, 123456789, time.UTC) cfg.DeployedAt = &deployedAt - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact(cfg.UUID, cfg, "") require.NoError(t, err) @@ -395,7 +395,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { })) defer server.Close() - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact("0000-test-api-0000-000000000000", createTestStoredConfig("RestApi"), "") assert.Error(t, err) assert.Contains(t, err.Error(), "500") @@ -423,7 +423,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { }, } - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact(cfg.UUID, cfg, "") assert.Error(t, err) assert.Contains(t, err.Error(), "project") @@ -583,7 +583,7 @@ func TestAPIUtilsService_FetchControlPlaneDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchControlPlaneDeployments(nil) assert.NoError(t, err) @@ -615,7 +615,7 @@ func TestAPIUtilsService_FetchControlPlaneDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchControlPlaneDeployments(&since) assert.NoError(t, err) @@ -630,7 +630,7 @@ func TestAPIUtilsService_FetchControlPlaneDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchControlPlaneDeployments(nil) assert.Error(t, err) @@ -640,7 +640,7 @@ func TestAPIUtilsService_FetchControlPlaneDeployments(t *testing.T) { t.Run("Connection error", func(t *testing.T) { cfg := PlatformAPIConfig{BaseURL: "http://localhost:99999", Token: "test-token", Timeout: 1 * time.Second} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchControlPlaneDeployments(nil) assert.Error(t, err) @@ -678,7 +678,7 @@ func TestAPIUtilsService_BatchFetchDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.BatchFetchDeployments([]string{"dep-789", "dep-456"}) assert.NoError(t, err) @@ -693,7 +693,7 @@ func TestAPIUtilsService_BatchFetchDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.BatchFetchDeployments([]string{"dep-789"}) assert.Error(t, err) @@ -733,7 +733,7 @@ func TestAPIUtilsService_FetchAPIKeysByKind_WebSubAPI(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) keys, err := svc.FetchAPIKeysByKind(models.KindWebSubApi, "") require.NoError(t, err) @@ -747,7 +747,7 @@ func TestAPIUtilsService_FetchAPIKeysByKind_WebSubAPI(t *testing.T) { func TestAPIUtilsService_ExtractDeploymentsFromBatchZip(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := PlatformAPIConfig{BaseURL: "http://localhost"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) t.Run("Extract multiple deployments", func(t *testing.T) { yamlContent1 := []byte("apiVersion: v1\nkind: RestApi\nmetadata:\n name: api-1") diff --git a/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go b/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go index d32c2c1a6a..9c947eca48 100644 --- a/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go +++ b/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go @@ -21,7 +21,7 @@ package utils import ( "archive/zip" "bytes" - "crypto/tls" + "context" "encoding/base64" "encoding/json" "fmt" @@ -40,14 +40,29 @@ import ( "gopkg.in/yaml.v3" ) +// defaultAPIMTimeout is the fallback per-call context budget used for on-prem APIM +// requests (token generation, import, undeploy, swagger fetch) when APIMConfig.Timeout is +// unset. Requests used to rely on a per-call client-level Timeout defaulting to this same +// value; now that a single shared *http.Client is reused, the budget is enforced via a +// context.WithTimeout deadline at each call site instead. +const defaultAPIMTimeout = 30 * time.Second + +// effectiveAPIMTimeout returns timeout if positive, otherwise defaultAPIMTimeout. +func effectiveAPIMTimeout(timeout time.Duration) time.Duration { + if timeout <= 0 { + return defaultAPIMTimeout + } + return timeout +} + // APIM publisher API path constants const ( - apimScheme = "https://" - apimPublisherBasePath = "/api/am/publisher/v4" - apimImportQueryParams = "?preserveProvider=false&overwrite=true&dryRun=false&rotateRevision=true" - apimImportPath = apimPublisherBasePath + "/apis/import" + apimImportQueryParams - apimUndeployPath = apimPublisherBasePath + "/apis/%s/undeploy-revision?revisionId=%s" - apimSwaggerPath = apimPublisherBasePath + "/apis/%s/swagger" + apimScheme = "https://" + apimPublisherBasePath = "/api/am/publisher/v4" + apimImportQueryParams = "?preserveProvider=false&overwrite=true&dryRun=false&rotateRevision=true" + apimImportPath = apimPublisherBasePath + "/apis/import" + apimImportQueryParams + apimUndeployPath = apimPublisherBasePath + "/apis/%s/undeploy-revision?revisionId=%s" + apimSwaggerPath = apimPublisherBasePath + "/apis/%s/swagger" ) // APIM zip entry path constants @@ -90,17 +105,17 @@ type APIMHubPolicy struct { // APIMOperation represents an operation in APIM format type APIMOperation struct { - Id string `json:"id" yaml:"id"` - Target string `json:"target" yaml:"target"` - Verb string `json:"verb" yaml:"verb"` - AuthType string `json:"authType" yaml:"authType"` - ThrottlingPolicy string `json:"throttlingPolicy" yaml:"throttlingPolicy"` - Scopes []interface{} `json:"scopes" yaml:"scopes"` - UsedProductIds []interface{} `json:"usedProductIds" yaml:"usedProductIds"` - PayloadSchema interface{} `json:"payloadSchema" yaml:"payloadSchema"` - UriMapping interface{} `json:"uriMapping" yaml:"uriMapping"` - OperationPolicies map[string]interface{} `json:"operationPolicies" yaml:"operationPolicies"` - OperationHubPolicies []APIMHubPolicy `json:"operationHubPolicies" yaml:"operationHubPolicies"` + Id string `json:"id" yaml:"id"` + Target string `json:"target" yaml:"target"` + Verb string `json:"verb" yaml:"verb"` + AuthType string `json:"authType" yaml:"authType"` + ThrottlingPolicy string `json:"throttlingPolicy" yaml:"throttlingPolicy"` + Scopes []interface{} `json:"scopes" yaml:"scopes"` + UsedProductIds []interface{} `json:"usedProductIds" yaml:"usedProductIds"` + PayloadSchema interface{} `json:"payloadSchema" yaml:"payloadSchema"` + UriMapping interface{} `json:"uriMapping" yaml:"uriMapping"` + OperationPolicies map[string]interface{} `json:"operationPolicies" yaml:"operationPolicies"` + OperationHubPolicies []APIMHubPolicy `json:"operationHubPolicies" yaml:"operationHubPolicies"` } // APIMCompleteStructure represents the complete APIM API structure for import @@ -128,32 +143,18 @@ type APIMConfig struct { // APIMTokenService manages authentication for on-prem APIM operations type APIMTokenService struct { config *APIMConfig + httpClient *http.Client cachedToken string tokenExpiry time.Time mu sync.Mutex } -// newAPIMPublisherHTTPClient creates an HTTP client with the given timeout and TLS settings. -// Defaults to 30 seconds if timeout is zero. -func newAPIMPublisherHTTPClient(timeout time.Duration, insecureSkipVerify bool) *http.Client { - if timeout == 0 { - timeout = 30 * time.Second - } - return &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: insecureSkipVerify, - MinVersion: tls.VersionTLS12, - }, - }, - } -} - -// NewAPIMTokenService creates a new APIM token service -func NewAPIMTokenService(config APIMConfig) APIMTokenService { +// NewAPIMTokenService creates a new APIM token service. httpClient is the single shared +// outbound *http.Client for this process, injected by the caller rather than built here. +func NewAPIMTokenService(config APIMConfig, httpClient *http.Client) APIMTokenService { return APIMTokenService{ - config: &config, + config: &config, + httpClient: httpClient, } } @@ -233,7 +234,9 @@ func (s *APIMTokenService) generateOAuth2Token() (string, int, error) { } // Create request - req, err := http.NewRequest("POST", s.config.TokenURL, strings.NewReader(body)) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(s.config.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", s.config.TokenURL, strings.NewReader(body)) if err != nil { return "", 0, fmt.Errorf("failed to create token request: %w", err) } @@ -244,10 +247,8 @@ func (s *APIMTokenService) generateOAuth2Token() (string, int, error) { req.Header.Set("Authorization", authHeader) } - client := newAPIMPublisherHTTPClient(s.config.Timeout, s.config.InsecureSkipVerify) - // Make request - resp, err := client.Do(req) + resp, err := s.httpClient.Do(req) if err != nil { return "", 0, fmt.Errorf("failed to send token request: %w", err) } @@ -293,8 +294,8 @@ func (s *APIMTokenService) generateOAuth2Token() (string, int, error) { // The zipFileBytes should contain the exported API definition as a zip file. // cpHost is the control plane host (e.g., "localhost:9443") // Returns ImportResponse with id and revision on success, error on failure (503 or other status codes). -func ImportAPIToAPIMWithConfig(apimConfig APIMConfig, logger *slog.Logger, apiZipName string, zipFileBytes *bytes.Buffer) (*OnPremAPIMImportResponse, error) { - tokenService := NewAPIMTokenService(apimConfig) +func ImportAPIToAPIMWithConfig(apimConfig APIMConfig, httpClient *http.Client, logger *slog.Logger, apiZipName string, zipFileBytes *bytes.Buffer) (*OnPremAPIMImportResponse, error) { + tokenService := NewAPIMTokenService(apimConfig, httpClient) // Construct the import URL with standard query parameters importURL := apimScheme + apimConfig.Host + apimImportPath @@ -323,16 +324,20 @@ func ImportAPIToAPIMWithConfig(apimConfig APIMConfig, logger *slog.Logger, apiZi return nil, fmt.Errorf("failed to close multipart writer: %w", err) } - // Create POST request - req, err := http.NewRequest("POST", importURL, body) + // Get access token for authentication BEFORE starting the import request's + // timeout: on a cache miss this performs its own separate OAuth request, + // which must not consume the import deadline below. + accessToken, err := tokenService.getAccessToken() if err != nil { - return nil, fmt.Errorf("failed to create import request: %w", err) + return nil, fmt.Errorf("failed to get access token: %w", err) } - // Get access token for authentication - accessToken, err := tokenService.getAccessToken() + // Create POST request + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(apimConfig.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", importURL, body) if err != nil { - return nil, fmt.Errorf("failed to get access token: %w", err) + return nil, fmt.Errorf("failed to create import request: %w", err) } // Add headers @@ -341,7 +346,7 @@ func ImportAPIToAPIMWithConfig(apimConfig APIMConfig, logger *slog.Logger, apiZi req.Header.Set("Accept", "application/json") // Make the request - resp, err := newAPIMPublisherHTTPClient(apimConfig.Timeout, apimConfig.InsecureSkipVerify).Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send import request: %w", err) } @@ -450,7 +455,9 @@ func (s *APIUtilsService) generateOAuth2Token() (string, int, error) { } // Create request - req, err := http.NewRequest("POST", s.TokenURL, strings.NewReader(body)) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(s.config.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", s.TokenURL, strings.NewReader(body)) if err != nil { return "", 0, fmt.Errorf("failed to create token request: %w", err) } @@ -541,16 +548,20 @@ func (s *APIUtilsService) ImportAPIToAPIM(apiZipName string, zipFileBytes *bytes return nil, fmt.Errorf("failed to close multipart writer: %w", err) } - // Create POST request - req, err := http.NewRequest("POST", importURL, body) + // Get access token for authentication BEFORE starting the import request's + // timeout: on a cache miss this performs its own separate OAuth request, + // which must not consume the import deadline below. + accessToken, err := s.getAccessToken() if err != nil { - return nil, fmt.Errorf("failed to create import request: %w", err) + return nil, fmt.Errorf("failed to get access token: %w", err) } - // Get access token for authentication - accessToken, err := s.getAccessToken() + // Create POST request + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", importURL, body) if err != nil { - return nil, fmt.Errorf("failed to get access token: %w", err) + return nil, fmt.Errorf("failed to create import request: %w", err) } // Add headers @@ -618,8 +629,8 @@ type ZipFile struct { // UndeployRevisionFromAPIM undeploys a specific API revision from a gateway in on-prem APIM. // Calls POST /api/am/publisher/v4/apis/{apiId}/undeploy-revision?revisionId={revisionId} // with the gateway name as the deployment environment. -func UndeployRevisionFromAPIM(apimConfig APIMConfig, apiID string, revisionID string, logger *slog.Logger) error { - tokenService := NewAPIMTokenService(apimConfig) +func UndeployRevisionFromAPIM(apimConfig APIMConfig, httpClient *http.Client, apiID string, revisionID string, logger *slog.Logger) error { + tokenService := NewAPIMTokenService(apimConfig, httpClient) token, err := tokenService.getAccessToken() if err != nil { @@ -640,14 +651,16 @@ func UndeployRevisionFromAPIM(apimConfig APIMConfig, apiID string, revisionID st return fmt.Errorf("failed to marshal undeploy payload: %w", err) } - req, err := http.NewRequest("POST", undeployURL, bytes.NewReader(bodyBytes)) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(apimConfig.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", undeployURL, bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("failed to create undeploy request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) - resp, err := newAPIMPublisherHTTPClient(apimConfig.Timeout, apimConfig.InsecureSkipVerify).Do(req) + resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send undeploy request: %w", err) } @@ -663,8 +676,8 @@ func UndeployRevisionFromAPIM(apimConfig APIMConfig, apiID string, revisionID st // FetchSwaggerFromAPIM fetches the OpenAPI/Swagger definition of an existing API from APIM. // Used during bottom-up sync updates to retrieve the current swagger instead of generating it locally. -func FetchSwaggerFromAPIM(apimConfig APIMConfig, apiID string, logger *slog.Logger) (string, error) { - tokenService := NewAPIMTokenService(apimConfig) +func FetchSwaggerFromAPIM(apimConfig APIMConfig, httpClient *http.Client, apiID string, logger *slog.Logger) (string, error) { + tokenService := NewAPIMTokenService(apimConfig, httpClient) token, err := tokenService.getAccessToken() if err != nil { @@ -674,13 +687,15 @@ func FetchSwaggerFromAPIM(apimConfig APIMConfig, apiID string, logger *slog.Logg swaggerURL := fmt.Sprintf(apimScheme+"%s"+apimSwaggerPath, apimConfig.Host, apiID) logger.Info("Fetching swagger from APIM", slog.String("url", swaggerURL)) - req, err := http.NewRequest("GET", swaggerURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(apimConfig.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", swaggerURL, nil) if err != nil { return "", fmt.Errorf("failed to create swagger request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) - resp, err := newAPIMPublisherHTTPClient(apimConfig.Timeout, apimConfig.InsecureSkipVerify).Do(req) + resp, err := httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to send swagger request: %w", err) } @@ -1026,15 +1041,15 @@ func buildAPIMOperation(op management.Operation) map[string]interface{} { operationHubPolicies := convertOperationPolicies(op.Policies) return map[string]interface{}{ - "id": "", - "target": op.EffectivePath(), - "verb": strings.ToUpper(op.EffectiveMethod()), - "authType": "Application & Application User", - "throttlingPolicy": "Unlimited", - "scopes": []interface{}{}, - "usedProductIds": []interface{}{}, - "payloadSchema": nil, - "uriMapping": nil, + "id": "", + "target": op.EffectivePath(), + "verb": strings.ToUpper(op.EffectiveMethod()), + "authType": "Application & Application User", + "throttlingPolicy": "Unlimited", + "scopes": []interface{}{}, + "usedProductIds": []interface{}{}, + "payloadSchema": nil, + "uriMapping": nil, "operationPolicies": map[string]interface{}{ "request": []interface{}{}, "response": []interface{}{}, diff --git a/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go b/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go index 3a4d933350..d39ab31397 100644 --- a/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go +++ b/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go @@ -33,7 +33,7 @@ func TestConstructorReplicaSyncWiring(t *testing.T) { apiKeyConfig := &config.APIKeyConfig{} t.Run("api deployment stores constructor wiring", func(t *testing.T) { - service := NewAPIDeploymentService(store, db, nil, nil, nil, newReplicaSyncTestEventHub(), " gateway-1 ", nil) + service := NewAPIDeploymentService(store, db, nil, nil, nil, newReplicaSyncTestEventHub(), " gateway-1 ", nil, nil) require.NotNil(t, service.eventHub) assert.Equal(t, "gateway-1", service.gatewayID) }) diff --git a/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go b/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go index 19f4124368..d5e7b5fd1a 100644 --- a/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go +++ b/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go @@ -1,15 +1,31 @@ package utils import ( + "fmt" + "net/http" + "github.com/wso2/api-platform/common/eventhub" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" + "github.com/wso2/api-platform/httpkit/httpclient" ) const testGatewayID = "test-gateway" +// testHTTPClient builds a plain outbound *http.Client for tests via httpkit's +// secure-by-default builder. Production code injects one single shared client built in +// cmd/controller/main.go; tests build their own throwaway instance since there is no shared +// process-level client to inject. +func testHTTPClient() *http.Client { + client, err := httpclient.New(httpclient.DefaultConfig()) + if err != nil { + panic(fmt.Sprintf("test HTTP client: unreachable construction error for a fixed default config: %v", err)) + } + return client +} + func newReplicaSyncTestEventHub() eventhub.EventHub { return &mockLLMEventHub{} } @@ -57,6 +73,7 @@ func newTestAPIDeploymentServiceWithHub( hub, gatewayID, nil, + testHTTPClient(), ) } diff --git a/gateway/gateway-controller/pkg/xds/server.go b/gateway/gateway-controller/pkg/xds/server.go index 83f0a3704a..d25e959dd1 100644 --- a/gateway/gateway-controller/pkg/xds/server.go +++ b/gateway/gateway-controller/pkg/xds/server.go @@ -20,6 +20,7 @@ package xds import ( "context" + "crypto/tls" "fmt" "net" "sync" @@ -36,22 +37,71 @@ import ( secretservice "github.com/envoyproxy/go-control-plane/envoy/service/secret/v3" "github.com/envoyproxy/go-control-plane/pkg/server/v3" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/metrics" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/tlsauth" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/keepalive" ) +// Bounds on the main xDS gRPC server (go-network-service-hardening.md +// directive 2 / go-control-plane-xds-security.md directive 5) -- unbounded +// defaults let one client (a misbehaving/compromised Envoy) exhaust memory +// or the stream-slot budget every other connection depends on. xDS +// snapshots can carry many routes/clusters, so the message ceiling is set +// well above gRPC's 4MB default. +const ( + xdsMaxMessageSize = 16 * 1024 * 1024 + xdsMaxConcurrentStreams = 1000 +) + // Server is the xDS gRPC server type Server struct { grpcServer *grpc.Server xdsServer server.Server snapshotManager *SnapshotManager port int + mtls *serverMTLS logger *slog.Logger } +// serverMTLS holds the resolved mutual-TLS state for the main xDS server. +type serverMTLS struct { + tlsConfig *tls.Config + allowedIdentities map[string]bool +} + +// ServerOption is a functional option for configuring the xDS Server. +type ServerOption func(*serverOptions) + +type serverOptions struct { + mtls *serverMTLS +} + +// WithMTLS enables mutual TLS on the main xDS gRPC server (serving Envoy). +// tlsConfig must come from config.BuildXDSServerTLSConfig, which already +// sets ClientAuth: tls.RequireAndVerifyClientCert -- server-only TLS is not +// offered here because this server distributes SDS secrets and full +// route/cluster config, so authenticating only the server side is not +// enough (go-control-plane-xds-security.md directive 2). allowedIdentities +// restricts accepted streams to peers whose certificate identity +// (tlsauth.PeerIdentity) is in the list. +func WithMTLS(tlsConfig *tls.Config, allowedIdentities []string) ServerOption { + return func(o *serverOptions) { + o.mtls = &serverMTLS{ + tlsConfig: tlsConfig, + allowedIdentities: tlsauth.AllowedSet(allowedIdentities), + } + } +} + // NewServer creates a new xDS server -func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretManager, port int, logger *slog.Logger, onFirstConnect chan struct{}) *Server { - grpcServer := grpc.NewServer( +func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretManager, port int, logger *slog.Logger, onFirstConnect chan struct{}, opts ...ServerOption) *Server { + var o serverOptions + for _, opt := range opts { + opt(&o) + } + + grpcOpts := []grpc.ServerOption{ grpc.KeepaliveParams(keepalive.ServerParameters{ Time: 30 * time.Second, Timeout: 5 * time.Second, @@ -60,11 +110,23 @@ func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretMana MinTime: 5 * time.Second, PermitWithoutStream: true, }), - ) + grpc.MaxRecvMsgSize(xdsMaxMessageSize), + grpc.MaxSendMsgSize(xdsMaxMessageSize), + grpc.MaxConcurrentStreams(xdsMaxConcurrentStreams), + } + + var allowedIdentities map[string]bool + if o.mtls != nil { + grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(o.mtls.tlsConfig))) + allowedIdentities = o.mtls.allowedIdentities + logger.Info("mTLS enabled for main xDS server") + } + + grpcServer := grpc.NewServer(grpcOpts...) // Create xDS server with the snapshot cache (shared with SDS) cache := snapshotManager.GetCache() - callbacks := NewServerCallbacks(logger, onFirstConnect) + callbacks := NewServerCallbacks(logger, onFirstConnect, allowedIdentities) xdsServer := server.NewServer(context.Background(), cache, callbacks) // Register xDS services @@ -85,6 +147,7 @@ func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretMana xdsServer: xdsServer, snapshotManager: snapshotManager, port: port, + mtls: o.mtls, logger: logger, } } @@ -96,7 +159,11 @@ func (s *Server) Start() error { return fmt.Errorf("failed to listen on port %d: %w", s.port, err) } - s.logger.Info("Starting xDS server", slog.Int("port", s.port)) + protocol := "insecure" + if s.mtls != nil { + protocol = "mTLS" + } + s.logger.Info("Starting xDS server", slog.Int("port", s.port), slog.String("protocol", protocol)) if err := s.grpcServer.Serve(listener); err != nil { return fmt.Errorf("failed to serve: %w", err) @@ -113,24 +180,33 @@ func (s *Server) Stop() { // serverCallbacks implements server.Callbacks type serverCallbacks struct { - logger *slog.Logger - activeStreams map[int64]string // stream_id -> node_id - activeStreamsMu sync.Mutex - onFirstConnect chan struct{} - firstConnectOnce sync.Once - pendingNonces map[int64]string // stream_id -> last sent nonce + logger *slog.Logger + activeStreams map[int64]string // stream_id -> node_id + activeStreamsMu sync.Mutex + onFirstConnect chan struct{} + firstConnectOnce sync.Once + pendingNonces map[int64]string // stream_id -> last sent nonce + allowedIdentities map[string]bool // nil when mTLS is not configured -- no identity check performed } -func NewServerCallbacks(logger *slog.Logger, onFirstConnect chan struct{}) *serverCallbacks { +func NewServerCallbacks(logger *slog.Logger, onFirstConnect chan struct{}, allowedIdentities map[string]bool) *serverCallbacks { return &serverCallbacks{ - logger: logger, - activeStreams: make(map[int64]string), - onFirstConnect: onFirstConnect, - pendingNonces: make(map[int64]string), + logger: logger, + activeStreams: make(map[int64]string), + onFirstConnect: onFirstConnect, + pendingNonces: make(map[int64]string), + allowedIdentities: allowedIdentities, } } func (cb *serverCallbacks) OnStreamOpen(ctx context.Context, id int64, typ string) error { + if cb.allowedIdentities != nil { + if err := tlsauth.VerifyStreamPeer(ctx, cb.allowedIdentities); err != nil { + cb.logger.Warn("xDS stream rejected: peer identity not authorized", + slog.Int64("stream_id", id), slog.Any("error", err)) + return err + } + } cb.logger.Info("xDS stream opened", slog.Int64("stream_id", id), slog.String("type", typ)) return nil } diff --git a/gateway/gateway-controller/pkg/xds/snapshot.go b/gateway/gateway-controller/pkg/xds/snapshot.go index bfd14c872d..4d58677707 100644 --- a/gateway/gateway-controller/pkg/xds/snapshot.go +++ b/gateway/gateway-controller/pkg/xds/snapshot.go @@ -137,8 +137,12 @@ func (sm *SnapshotManager) UpdateSnapshot(ctx context.Context, correlationID str return fmt.Errorf("failed to translate configurations: %w", err) } - // Add SDS secrets if SDS secret manager is configured - if sm.sdsSecretManager != nil { + // Add the SDS secret only when this snapshot's clusters actually reference it. + // Envoy never issues a watch for the Secret type URL unless a Cluster it accepted + // points at that secret name via SDS, so pushing it unconditionally just produces + // an "Ignoring unwatched type URL ... Secret" warning whenever no HTTPS-scheme + // upstream is configured. + if sm.sdsSecretManager != nil && ClusterResourcesReferenceUpstreamCASecret(resources[resource.ClusterType]) { secret, err := sm.sdsSecretManager.GetSecret() if err != nil { log.Warn("Failed to get SDS secret, continuing without it", slog.Any("error", err)) diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index 0b4f745fd5..ca65e01235 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -930,13 +930,6 @@ func (t *Translator) TranslateConfigs( } } - // Add SDS cluster if cert store is enabled - // This cluster allows Envoy to fetch certificates from the SDS service - if t.certStore != nil { - sdsCluster := t.createSDSCluster() - clusters = append(clusters, sdsCluster) - } - // Add OTEL collector cluster if tracing is enabled // This cluster allows Envoy to send traces to OpenTelemetry collector if t.config.TracingConfig.Enabled { @@ -2227,63 +2220,6 @@ func (t *Translator) createOTELCollectorCluster() *cluster.Cluster { return c } -// createSDSCluster creates an Envoy cluster for the SDS (Secret Discovery Service) -// This cluster allows Envoy to fetch TLS certificates dynamically via xDS -func (t *Translator) createSDSCluster() *cluster.Cluster { - // SDS uses the same xDS server - // In containerized environments, Envoy connects to the gateway-controller container - // Use the same host/port configuration as the main xDS connection - xdsHost := "gateway-controller" // Default for Docker Compose - if envHost := os.Getenv("GATEWAY_CONTROLLER_HOST"); envHost != "" { - xdsHost = envHost - } - - xdsPort := t.config.Controller.Server.XDSPort - if xdsPort == 0 { - xdsPort = 18000 // Default xDS port - } - - address := &core.Address{ - Address: &core.Address_SocketAddress{ - SocketAddress: &core.SocketAddress{ - Protocol: core.SocketAddress_TCP, - Address: xdsHost, - PortSpecifier: &core.SocketAddress_PortValue{ - PortValue: uint32(xdsPort), - }, - }, - }, - } - - lbEndpoint := &endpoint.LbEndpoint{ - HostIdentifier: &endpoint.LbEndpoint_Endpoint{ - Endpoint: &endpoint.Endpoint{ - Address: address, - }, - }, - } - - localityLbEndpoints := &endpoint.LocalityLbEndpoints{ - LbEndpoints: []*endpoint.LbEndpoint{lbEndpoint}, - } - - // Create the SDS cluster - // Note: SDS must use HTTP/2 for gRPC communication - return &cluster.Cluster{ - Name: "sds_cluster", - ConnectTimeout: durationpb.New(5 * time.Second), - ClusterDiscoveryType: &cluster.Cluster_Type{Type: cluster.Cluster_STRICT_DNS}, - DnsLookupFamily: cluster.Cluster_V4_PREFERRED, - LbPolicy: cluster.Cluster_ROUND_ROBIN, - LoadAssignment: &endpoint.ClusterLoadAssignment{ - ClusterName: "sds_cluster", - Endpoints: []*endpoint.LocalityLbEndpoints{localityLbEndpoints}, - }, - // Enable HTTP/2 for gRPC - Http2ProtocolOptions: &core.Http2ProtocolOptions{}, - } -} - // createUpstreamTLSContext creates an upstream TLS context for secure connections func (t *Translator) createUpstreamTLSContext(certificate []byte, address string) *tlsv3.UpstreamTlsContext { // Create TLS context with base configuration @@ -2317,24 +2253,25 @@ func (t *Translator) createUpstreamTLSContext(certificate []byte, address string // 4. If none provided, Envoy falls back to system default trust store if t.certStore != nil { - // Use SDS to dynamically fetch certificates - // This is more efficient than inlining certificates in every cluster config + // Use SDS to dynamically fetch certificates, riding the same ADS + // stream Envoy already has open for LDS/CDS/RDS (bootstrap + // xds_cluster, see envoy-bootstrap.yaml's dynamic_resources). + // The SDS service is registered on the same gRPC server/cache as + // the main xDS server (see xds/server.go), so no dedicated + // cluster or connection is needed -- this also means + // gateway-controller never needs to know any TLS client cert/key + // paths that live on gateway-runtime's filesystem: the ADS + // connection's own TLS is entirely gateway-runtime's concern, + // configured in its own bootstrap (docker-entrypoint.sh + + // config-override.yaml's xds_cluster), independent of this + // process. A prior version of this pushed a second CDS cluster + // ("sds_cluster") that duplicated xds_cluster's host:port and + // required this process to embed gateway-runtime-local file + // paths -- removed in favor of this ADS-based reference. sdsConfig := &core.ConfigSource{ ResourceApiVersion: core.ApiVersion_V3, - ConfigSourceSpecifier: &core.ConfigSource_ApiConfigSource{ - ApiConfigSource: &core.ApiConfigSource{ - ApiType: core.ApiConfigSource_GRPC, - TransportApiVersion: core.ApiVersion_V3, - GrpcServices: []*core.GrpcService{ - { - TargetSpecifier: &core.GrpcService_EnvoyGrpc_{ - EnvoyGrpc: &core.GrpcService_EnvoyGrpc{ - ClusterName: "sds_cluster", - }, - }, - }, - }, - }, + ConfigSourceSpecifier: &core.ConfigSource_Ads{ + Ads: &core.AggregatedConfigSource{}, }, } @@ -2410,6 +2347,41 @@ func (t *Translator) createUpstreamTLSContext(certificate []byte, address string return upstreamTLSContext } +// ClusterResourcesReferenceUpstreamCASecret reports whether any cluster in +// clusters attaches the upstream CA bundle via SDS (ValidationContextSdsSecretConfig +// named SecretNameUpstreamCA). Envoy only issues a watch for the Secret type URL +// once a Cluster it has actually accepted references that secret name, so the +// snapshot manager uses this to decide whether including the Secret resource in +// a given snapshot version is warranted, rather than pushing it unconditionally +// and having Envoy log "Ignoring unwatched type URL ... Secret" when no +// HTTPS-scheme upstream is configured. +func ClusterResourcesReferenceUpstreamCASecret(clusters []types.Resource) bool { + for _, res := range clusters { + c, ok := res.(*cluster.Cluster) + if !ok { + continue + } + for _, tsm := range c.GetTransportSocketMatches() { + typedConfig := tsm.GetTransportSocket().GetTypedConfig() + if typedConfig == nil { + continue + } + var tlsCtx tlsv3.UpstreamTlsContext + if err := typedConfig.UnmarshalTo(&tlsCtx); err != nil { + continue + } + combined, ok := tlsCtx.GetCommonTlsContext().GetValidationContextType().(*tlsv3.CommonTlsContext_CombinedValidationContext) + if !ok { + continue + } + if combined.CombinedValidationContext.GetValidationContextSdsSecretConfig().GetName() == SecretNameUpstreamCA { + return true + } + } + } + return false +} + // createDownstreamTLSContext creates a downstream TLS context for HTTPS listeners func (t *Translator) createDownstreamTLSContext() (*tlsv3.DownstreamTlsContext, error) { // Read certificate and key files diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 283de7ac77..4fb9379475 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -43,6 +43,7 @@ import ( "github.com/stretchr/testify/require" commonconstants "github.com/wso2/api-platform/common/constants" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/certstore" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" @@ -2497,14 +2498,35 @@ func TestNotEffectivelyMatchesPrefix(t *testing.T) { } } -func TestTranslator_CreateSDSCluster(t *testing.T) { +// TestTranslator_CreateUpstreamTLSContext_SDSViaADS verifies that, when a +// cert store is configured, the upstream validation context's SDS reference +// rides the existing ADS stream (ConfigSource_Ads) rather than naming a +// dedicated cluster. This means gateway-controller never needs to construct +// a TLS transport socket pointing at cert/key/CA file paths that only exist +// on gateway-runtime's filesystem -- that connection's TLS is entirely +// gateway-runtime's own concern (its bootstrap xds_cluster). +func TestTranslator_CreateUpstreamTLSContext_SDSViaADS(t *testing.T) { logger := createTestLogger() routerCfg := testRouterConfig() + routerCfg.Upstream.TLS.DisableSslVerification = false cfg := testConfig() translator := NewTranslator(logger, routerCfg, nil, cfg) + // Only t.certStore != nil matters for this code path -- construct one + // directly rather than routing through NewTranslator's CustomCertsPath + // init, which calls LoadCertificates against a real db.Storage. + translator.certStore = certstore.NewCertStore(logger, nil, "", "") - cluster := translator.createSDSCluster() - assert.NotNil(t, cluster) + tlsContext := translator.createUpstreamTLSContext(nil, "example.com") + require.NotNil(t, tlsContext) + + combinedCtx := tlsContext.CommonTlsContext.GetCombinedValidationContext() + require.NotNil(t, combinedCtx) + sdsConfig := combinedCtx.GetValidationContextSdsSecretConfig().GetSdsConfig() + require.NotNil(t, sdsConfig) + + ads := sdsConfig.GetAds() + assert.NotNil(t, ads, "SDS config should ride the ADS stream rather than naming a dedicated cluster") + assert.Nil(t, sdsConfig.GetApiConfigSource(), "SDS config should not name a dedicated grpc cluster") } func TestTranslator_CreateUpstreamTLSContext(t *testing.T) { diff --git a/gateway/gateway-controller/tests/integration/vhost_test.go b/gateway/gateway-controller/tests/integration/vhost_test.go index 2d5b74ab03..b0f5316095 100644 --- a/gateway/gateway-controller/tests/integration/vhost_test.go +++ b/gateway/gateway-controller/tests/integration/vhost_test.go @@ -83,7 +83,7 @@ spec: validator := config.NewAPIValidator() fullCfg := &config.Config{Router: *routerCfg} snapshotManager := xds.NewSnapshotManager(store, logger, routerCfg, db, fullCfg) - svc := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerCfg, integrationTestEventHub{}, "test-gateway", nil) + svc := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerCfg, integrationTestEventHub{}, "test-gateway", nil, nil) return svc, db } From cf7f2b7d5b22512cc7793f321bd859b06e3234bf Mon Sep 17 00:00:00 2001 From: Tharindu Dharmarathna Date: Fri, 28 Aug 2026 16:19:27 +0530 Subject: [PATCH 2/2] event-gateway: fix bug --- .../gateway-runtime/internal/config/config.go | 18 ++--- .../internal/config/httpclient.go | 24 ++++--- .../internal/config/httpclient_test.go | 4 +- .../gateway-controller/pkg/config/config.go | 28 +++++--- .../pkg/config/httpclient_config.go | 10 ++- .../pkg/config/httpclient_config_test.go | 68 +++++++++++++++++++ .../pkg/controlplane/api_deleted_test.go | 4 +- .../pkg/utils/on_prem_apim_utils.go | 2 +- .../pkg/utils/on_prem_apim_utils_test.go | 35 ++++++++++ 9 files changed, 159 insertions(+), 34 deletions(-) create mode 100644 gateway/gateway-controller/pkg/config/httpclient_config_test.go diff --git a/event-gateway/gateway-runtime/internal/config/config.go b/event-gateway/gateway-runtime/internal/config/config.go index 2711a4813b..7ed38a584e 100644 --- a/event-gateway/gateway-runtime/internal/config/config.go +++ b/event-gateway/gateway-runtime/internal/config/config.go @@ -145,11 +145,14 @@ type LoggingConfig struct { // Unlike gateway-controller's HTTPClientConfig, SSRF protection is NOT configurable // off: every CallbackURL dialed by either client is tenant/subscriber-supplied — // exactly the scenario ssrf-prevention.md targets — so SSRF.Enabled=true and -// netguard.PublicOnly() are hardcoded in BuildHTTPClientConfig rather than sourced -// from this struct. PublicOnly (not PermitPrivateBlockMetadata) is deliberate: a -// tenant-supplied CallbackURL must never be usable to reach an operator's own -// private/loopback network, only the public internet. Only the redirect/scheme -// knobs netguard exposes are configurable, via HTTPClientSSRFConfig. +// netguard.PermitPrivateBlockMetadata() are hardcoded in BuildHTTPClientConfig rather +// than sourced from this struct. PermitPrivateBlockMetadata (not the stricter PublicOnly) +// is deliberate: WebSub subscribers routinely live on private networks by design (a +// Kubernetes ClusterIP, a docker-compose service, a localhost port during development — +// see the preset's own doc comment), so blocking RFC 1918/loopback would break that core, +// intended deployment shape. Link-local (where the cloud metadata endpoint lives), +// unspecified, and multicast/broadcast addresses stay refused regardless. Only the +// redirect/scheme knobs netguard exposes are configurable, via HTTPClientSSRFConfig. type HTTPClientConfig struct { Pooling HTTPClientPoolingConfig `koanf:"pooling"` Timeouts HTTPClientTimeoutsConfig `koanf:"timeouts"` @@ -241,9 +244,8 @@ type HTTPClientProxyTLSConfig struct { // HTTPClientSSRFConfig mirrors the TOML-expressible redirect/scheme knobs of // httpclient.SSRFConfig. It deliberately has NO Enabled/Preset field: SSRF guarding for -// this client is always on with netguard.PublicOnly() (see HTTPClientConfig's doc -// comment) — there is no supported way to disable it, or to permit private/loopback -// destinations, via config. +// this client is always on with netguard.PermitPrivateBlockMetadata() (see +// HTTPClientConfig's doc comment) — there is no supported way to disable it via config. type HTTPClientSSRFConfig struct { MaxRedirects int `koanf:"max_redirects"` // 0 uses netguard's own default (5) AllowedSchemes []string `koanf:"allowed_schemes"` // empty defaults to {"https"} diff --git a/event-gateway/gateway-runtime/internal/config/httpclient.go b/event-gateway/gateway-runtime/internal/config/httpclient.go index f6ede34e25..8ed866fa1a 100644 --- a/event-gateway/gateway-runtime/internal/config/httpclient.go +++ b/event-gateway/gateway-runtime/internal/config/httpclient.go @@ -32,11 +32,18 @@ import ( // own existing per-call-site timeout (Verifier's `timeout` argument / Deliverer's own // delivery timeout) — this function only fills in the common default. // -// SSRF guarding is unconditionally enabled here with netguard.PublicOnly() — every -// caller of this shared config dials a tenant/subscriber-supplied CallbackURL, which -// must never be usable to reach an operator's own private/loopback network, so unlike -// gateway-controller's analogous translation there is no Enabled/Preset switch to +// SSRF guarding is unconditionally enabled here with netguard.PermitPrivateBlockMetadata() +// — every caller of this shared config dials a tenant/subscriber-supplied CallbackURL, so +// unlike gateway-controller's analogous translation there is no Enabled/Preset switch to // interpret; only the redirect/scheme knobs in HTTPClientSSRFConfig are read from config. +// PermitPrivateBlockMetadata (not PublicOnly) is deliberate here, not an oversight: WebSub +// subscribers routinely live on private networks by design (a Kubernetes ClusterIP, a +// service-DNS name resolving into RFC 1918 space, a docker-compose service, a localhost +// port during development — see the preset's own doc comment) — blocking RFC 1918/loopback +// would break that core, intended deployment shape. What must still be refused is a +// destination that is never a legitimate subscriber upstream: link-local (which is where +// the cloud metadata endpoint 169.254.169.254 lives), the unspecified address, and +// multicast/broadcast — exactly what this preset blocks. func BuildHTTPClientConfig(hc HTTPClientConfig) (httpclient.Config, error) { cfg := httpclient.DefaultConfig() @@ -99,13 +106,10 @@ func BuildHTTPClientConfig(hc HTTPClientConfig) (httpclient.Config, error) { return httpclient.Config{}, fmt.Errorf("http_client.proxy.mode: unrecognized value %q (want \"none\", \"environment\", or \"url\")", hc.Proxy.Mode) } - // Always on — see this function's doc comment and HTTPClientConfig's doc comment. - // PublicOnly (not PermitPrivateBlockMetadata) is deliberate: PermitPrivateBlockMetadata - // permits private/loopback/CGNAT addresses, which is appropriate for an - // operator-configured backend but not for a tenant/subscriber-supplied CallbackURL, - // which must never be usable to reach a private network service. + // Always on — see this function's doc comment and HTTPClientConfig's doc comment for + // why PermitPrivateBlockMetadata (not PublicOnly) is the deliberate choice here. cfg.SSRF.Enabled = true - cfg.SSRF.Policy = netguard.PublicOnly() + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() cfg.SSRF.Policy.AllowedSchemes = hc.SSRF.AllowedSchemes cfg.SSRF.MaxRedirects = hc.SSRF.MaxRedirects diff --git a/event-gateway/gateway-runtime/internal/config/httpclient_test.go b/event-gateway/gateway-runtime/internal/config/httpclient_test.go index f659b3ad4b..538553e2f1 100644 --- a/event-gateway/gateway-runtime/internal/config/httpclient_test.go +++ b/event-gateway/gateway-runtime/internal/config/httpclient_test.go @@ -39,9 +39,9 @@ func TestBuildHTTPClientConfigDefaultsAlwaysEnableSSRFGuard(t *testing.T) { if !cfg.SSRF.Enabled { t.Fatal("expected SSRF.Enabled to always be true, got false") } - wantPolicy := netguard.PublicOnly() + wantPolicy := netguard.PermitPrivateBlockMetadata() if !reflect.DeepEqual(cfg.SSRF.Policy, wantPolicy) { - t.Fatalf("expected SSRF.Policy to be PublicOnly, got %+v", cfg.SSRF.Policy) + t.Fatalf("expected SSRF.Policy to be PermitPrivateBlockMetadata, got %+v", cfg.SSRF.Policy) } if cfg.Proxy.Mode != "" { t.Fatalf("expected no proxy mode by default, got %q", cfg.Proxy.Mode) diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index 3fbfcf29e6..3711fb9ef4 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -819,12 +819,14 @@ type HTTPClientPoolingConfig struct { // HTTPClientTimeoutsConfig mirrors httpclient.TimeoutsConfig. type HTTPClientTimeoutsConfig struct { - Overall time.Duration `koanf:"overall"` // safety-net only; see HTTPClientConfig's doc comment - Dial time.Duration `koanf:"dial"` - TLSHandshake time.Duration `koanf:"tls_handshake"` - ResponseHeader time.Duration `koanf:"response_header"` - ExpectContinue time.Duration `koanf:"expect_continue"` - MaxResponseBytes int64 `koanf:"max_response_bytes"` // 0 = package default (10MiB); negative disables the bound + Overall time.Duration `koanf:"overall"` // safety-net only; see HTTPClientConfig's doc comment + Dial time.Duration `koanf:"dial"` + TLSHandshake time.Duration `koanf:"tls_handshake"` + ResponseHeader time.Duration `koanf:"response_header"` + ExpectContinue time.Duration `koanf:"expect_continue"` + // MaxResponseBytes bounds a response body. 0 = package default (10MiB). A negative + // value is rejected by BuildHTTPClientConfig rather than disabling the bound. + MaxResponseBytes int64 `koanf:"max_response_bytes"` } // HTTPClientTLSConfig mirrors the TOML-expressible subset of httpclient.TLSConfig. @@ -861,10 +863,16 @@ type HTTPClientProxyConfig struct { // HTTPClientProxyTLSConfig mirrors httpclient.ProxyTLSConfig (the proxy's own TLS // handshake, fully decoupled from the origin TLS handshake in HTTPClientTLSConfig). type HTTPClientProxyTLSConfig struct { - RootCAFile string `koanf:"root_ca_file"` - ClientCertFile string `koanf:"client_cert_file"` - ClientKeyFile string `koanf:"client_key_file"` - InsecureSkipVerify bool `koanf:"insecure_skip_verify"` + RootCAFile string `koanf:"root_ca_file"` + ClientCertFile string `koanf:"client_cert_file"` + ClientKeyFile string `koanf:"client_key_file"` + // InsecureSkipVerify and InsecureSkipVerifyAcknowledged are deliberately separate + // fields: httpkit's own acknowledgement gate (httpclient.ProxyTLSConfig) requires an + // operator to opt into disabling verification twice, once per field, so a single + // "insecure_skip_verify = true" in TOML can't silently satisfy its own gate. Both must + // be explicitly set to true for InsecureSkipVerify to take effect. + InsecureSkipVerify bool `koanf:"insecure_skip_verify"` + InsecureSkipVerifyAcknowledged bool `koanf:"insecure_skip_verify_acknowledged"` } // HTTPClientSSRFConfig mirrors the TOML-expressible subset of httpclient.SSRFConfig. Off by diff --git a/gateway/gateway-controller/pkg/config/httpclient_config.go b/gateway/gateway-controller/pkg/config/httpclient_config.go index b16362f2a5..b18047cc87 100644 --- a/gateway/gateway-controller/pkg/config/httpclient_config.go +++ b/gateway/gateway-controller/pkg/config/httpclient_config.go @@ -50,6 +50,14 @@ func BuildHTTPClientConfig(hc HTTPClientConfig, insecureSkipVerify bool) (httpcl cfg.Timeouts.TLSHandshake = hc.Timeouts.TLSHandshake cfg.Timeouts.ResponseHeader = hc.Timeouts.ResponseHeader cfg.Timeouts.ExpectContinue = hc.Timeouts.ExpectContinue + // A negative value would disable httpkit's response-size bound entirely (see + // httpclient.TimeoutsConfig.MaxResponseBytes) -- reject it rather than forwarding it, + // so a stray/typo'd negative config value can't silently turn into an unbounded read. + // 0 still selects httpkit's own finite default (10MiB); a caller that genuinely needs + // a larger bound can set an explicit large positive value instead of disabling it. + if hc.Timeouts.MaxResponseBytes < 0 { + return httpclient.Config{}, fmt.Errorf("controller.http_client.timeouts.max_response_bytes must not be negative") + } cfg.Timeouts.MaxResponseBytes = hc.Timeouts.MaxResponseBytes cfg.TLS.MinVersion = hc.TLS.MinVersion @@ -79,7 +87,7 @@ func BuildHTTPClientConfig(hc HTTPClientConfig, insecureSkipVerify bool) (httpcl ClientCertFile: hc.Proxy.TLS.ClientCertFile, ClientKeyFile: hc.Proxy.TLS.ClientKeyFile, InsecureSkipVerify: hc.Proxy.TLS.InsecureSkipVerify, - InsecureSkipVerifyAcknowledged: hc.Proxy.TLS.InsecureSkipVerify, + InsecureSkipVerifyAcknowledged: hc.Proxy.TLS.InsecureSkipVerifyAcknowledged, } } default: diff --git a/gateway/gateway-controller/pkg/config/httpclient_config_test.go b/gateway/gateway-controller/pkg/config/httpclient_config_test.go new file mode 100644 index 0000000000..07e2bc8eaf --- /dev/null +++ b/gateway/gateway-controller/pkg/config/httpclient_config_test.go @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import "testing" + +func TestBuildHTTPClientConfigProxyTLSRequiresSeparateAcknowledgement(t *testing.T) { + hc := HTTPClientConfig{ + Proxy: HTTPClientProxyConfig{ + Mode: "url", + URL: "https://proxy.example.com:3128", + Egress: "delegated", + TLS: HTTPClientProxyTLSConfig{ + InsecureSkipVerify: true, + // InsecureSkipVerifyAcknowledged intentionally left unset. + }, + }, + } + + cfg, err := BuildHTTPClientConfig(hc, false) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + if cfg.Proxy.ProxyTLS == nil { + t.Fatal("expected ProxyTLS to be set") + } + if !cfg.Proxy.ProxyTLS.InsecureSkipVerify { + t.Fatal("expected InsecureSkipVerify to be carried through") + } + if cfg.Proxy.ProxyTLS.InsecureSkipVerifyAcknowledged { + t.Fatal("expected InsecureSkipVerifyAcknowledged to stay false when not explicitly set, independent of InsecureSkipVerify") + } + + hc.Proxy.TLS.InsecureSkipVerifyAcknowledged = true + cfg, err = BuildHTTPClientConfig(hc, false) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + if !cfg.Proxy.ProxyTLS.InsecureSkipVerifyAcknowledged { + t.Fatal("expected InsecureSkipVerifyAcknowledged to be carried through once explicitly set") + } +} + +func TestBuildHTTPClientConfigRejectsNegativeMaxResponseBytes(t *testing.T) { + hc := HTTPClientConfig{ + Timeouts: HTTPClientTimeoutsConfig{MaxResponseBytes: -1}, + } + + if _, err := BuildHTTPClientConfig(hc, false); err == nil { + t.Fatal("expected error for negative controller.http_client.timeouts.max_response_bytes, got nil") + } +} diff --git a/gateway/gateway-controller/pkg/controlplane/api_deleted_test.go b/gateway/gateway-controller/pkg/controlplane/api_deleted_test.go index 1e3124884b..95697eb6e2 100644 --- a/gateway/gateway-controller/pkg/controlplane/api_deleted_test.go +++ b/gateway/gateway-controller/pkg/controlplane/api_deleted_test.go @@ -1416,7 +1416,7 @@ func TestClient_syncAPIKeysForExistingArtifacts_MapsControlPlaneArtifactIDToLoca store: configStore, apiKeyStore: storage.NewAPIKeyStore(logger), apiKeyService: utils.NewAPIKeyService(configStore, db, nil, nil, hub, "test-gateway"), - apiUtilsService: utils.NewAPIUtilsService(utils.PlatformAPIConfig{BaseURL: srv.URL, Token: "t"}, logger), + apiUtilsService: utils.NewAPIUtilsService(utils.PlatformAPIConfig{BaseURL: srv.URL, Token: "t"}, srv.Client(), logger), } client.syncAPIKeysForExistingArtifacts("test-gateway") @@ -1475,7 +1475,7 @@ func TestClient_syncAPIKeysForExistingArtifacts_LeavesControlPlaneOriginatedKeys store: configStore, apiKeyStore: storage.NewAPIKeyStore(logger), apiKeyService: utils.NewAPIKeyService(configStore, db, nil, nil, hub, "test-gateway"), - apiUtilsService: utils.NewAPIUtilsService(utils.PlatformAPIConfig{BaseURL: srv.URL, Token: "t"}, logger), + apiUtilsService: utils.NewAPIUtilsService(utils.PlatformAPIConfig{BaseURL: srv.URL, Token: "t"}, srv.Client(), logger), } client.syncAPIKeysForExistingArtifacts("test-gateway") diff --git a/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go b/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go index 9c947eca48..d5c19eefa0 100644 --- a/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go +++ b/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go @@ -557,7 +557,7 @@ func (s *APIUtilsService) ImportAPIToAPIM(apiZipName string, zipFileBytes *bytes } // Create POST request - ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(s.config.Timeout)) defer cancel() req, err := http.NewRequestWithContext(ctx, "POST", importURL, body) if err != nil { diff --git a/gateway/gateway-controller/pkg/utils/on_prem_apim_utils_test.go b/gateway/gateway-controller/pkg/utils/on_prem_apim_utils_test.go index 1363c02eec..a0deb2edf4 100644 --- a/gateway/gateway-controller/pkg/utils/on_prem_apim_utils_test.go +++ b/gateway/gateway-controller/pkg/utils/on_prem_apim_utils_test.go @@ -21,8 +21,13 @@ package utils import ( "archive/zip" "bytes" + "io" + "log/slog" + "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -212,6 +217,36 @@ func TestExportAPIAsZip_MissingUpstreamURL(t *testing.T) { assert.Error(t, err, "should return error when upstream URL is missing") } +// TestImportAPIToAPIM_ZeroTimeoutFallsBackToDefault regression-tests a bug where the +// import request's context was built from the raw s.config.Timeout instead of +// effectiveAPIMTimeout(s.config.Timeout): a zero Timeout (e.g. an APIUtilsService +// constructed without going through NewAPIUtilsService's defaulting, as +// controlplane/sync_secrets_test.go does) produced an already-expired context, failing +// the request before it ever reached the network. +func TestImportAPIToAPIM_ZeroTimeoutFallsBackToDefault(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"api-1","revision":"rev-1"}`)) + })) + defer server.Close() + + svc := &APIUtilsService{ + config: PlatformAPIConfig{ + BaseURL: server.URL, + Timeout: 0, // regression condition: must fall back to defaultAPIMTimeout + }, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + client: server.Client(), + cachedToken: "test-token", + tokenExpiry: time.Now().Add(time.Hour), // skip the OAuth2 token flow + } + + resp, err := svc.ImportAPIToAPIM("api.zip", bytes.NewBufferString("fake-zip-content"), "") + require.NoError(t, err) + assert.Equal(t, "api-1", resp.ID) + assert.Equal(t, "rev-1", resp.Revision) +} + // TestExportAPIAsZip_InvalidConfiguration verifies that ExportAPIAsZip returns an error func TestExportAPIAsZip_InvalidConfiguration(t *testing.T) { api := &models.StoredConfig{