From 8e2cb97138239cc25dfb306b3c194ccdf42e83dd Mon Sep 17 00:00:00 2001 From: hfuss Date: Tue, 9 Jun 2026 14:25:49 -0400 Subject: [PATCH 01/10] [fftls] Metrics for CA/Client/Server Certificate Expiry Signed-off-by: hfuss --- pkg/fftls/certexpiry_test.go | 215 +++++++++++++++++++++++++++++++ pkg/fftls/fftls.go | 110 ++++++++++++++++ pkg/fftls/testdata/ca-a-cert.pem | 20 +++ pkg/fftls/testdata/ca-b-cert.pem | 20 +++ pkg/fftls/testdata/ca-bundle.pem | 40 ++++++ pkg/fftls/testdata/leaf-cert.pem | 20 +++ pkg/fftls/testdata/leaf-key.pem | 28 ++++ 7 files changed, 453 insertions(+) create mode 100644 pkg/fftls/certexpiry_test.go create mode 100644 pkg/fftls/testdata/ca-a-cert.pem create mode 100644 pkg/fftls/testdata/ca-b-cert.pem create mode 100644 pkg/fftls/testdata/ca-bundle.pem create mode 100644 pkg/fftls/testdata/leaf-cert.pem create mode 100644 pkg/fftls/testdata/leaf-key.pem diff --git a/pkg/fftls/certexpiry_test.go b/pkg/fftls/certexpiry_test.go new file mode 100644 index 0000000..49fe9bd --- /dev/null +++ b/pkg/fftls/certexpiry_test.go @@ -0,0 +1,215 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed 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 fftls + +import ( + "context" + "crypto/x509" + _ "embed" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/metric" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The certificates below are static self-signed fixtures generated once with openssl, e.g.: +// +// openssl req -x509 -newkey rsa:2048 -keyout leaf-key.pem -out leaf-cert.pem \ +// -days 3650 -nodes -subj "/CN=leaf/O=FireFly" -addext "subjectAltName=IP:127.0.0.1" +// +// ca-bundle.pem is the concatenation of ca-a-cert.pem and ca-b-cert.pem. +var ( + //go:embed testdata/ca-a-cert.pem + caACertPEM []byte + //go:embed testdata/ca-bundle.pem + caBundlePEM []byte + //go:embed testdata/leaf-cert.pem + leafCertPEM []byte + //go:embed testdata/leaf-key.pem + leafKeyPEM []byte +) + +func mustParseCert(t *testing.T, pemBytes []byte) *x509.Certificate { + block, _ := pem.Decode(pemBytes) + require.NotNil(t, block) + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + return cert +} + +func writeTemp(t *testing.T, dir, name string, content []byte) string { + p := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(p, content, 0600)) + return p +} + +// findCertExpiry gathers from the registry and returns the value of the ff_tls_certificate_expiry +// gauge for the series matching the given "type" label and whose "subject" label contains the supplied +// substring. +func findCertExpiry(t *testing.T, mr metric.MetricsRegistry, certType, subjectSubstr string) (float64, bool) { + mfs, err := mr.GetGatherer().Gather() + require.NoError(t, err) + for _, mf := range mfs { + if mf.GetName() != "ff_tls_certificate_expiry" { + continue + } + for _, m := range mf.GetMetric() { + var typeMatch, subjectMatch bool + for _, lp := range m.GetLabel() { + if lp.GetName() == "type" && lp.GetValue() == certType { + typeMatch = true + } + if lp.GetName() == "subject" && strings.Contains(lp.GetValue(), subjectSubstr) { + subjectMatch = true + } + } + if typeMatch && subjectMatch { + return m.GetGauge().GetValue(), true + } + } + } + return 0, false +} + +// enableTestCertMetrics resets the package-level manager and binds it to a fresh registry for the test. +func enableTestCertMetrics(t *testing.T, component string) metric.MetricsRegistry { + certMetricsManager = nil + t.Cleanup(func() { certMetricsManager = nil }) + mr := metric.NewPrometheusMetricsRegistry(component) + require.NoError(t, EnableCertificateMetrics(context.Background(), mr)) + return mr +} + +func TestEnableCertificateMetricsRecordsCAAndClientExpiry(t *testing.T) { + mr := enableTestCertMetrics(t, "test_fftls_client") + + dir := t.TempDir() + caBundleFile := writeTemp(t, dir, "ca-bundle.pem", caBundlePEM) + leafCertFile := writeTemp(t, dir, "leaf-cert.pem", leafCertPEM) + leafKeyFile := writeTemp(t, dir, "leaf-key.pem", leafKeyPEM) + + caA := mustParseCert(t, caACertPEM) + caB := mustParseCert(t, secondCertInBundle(t, caBundlePEM)) + leaf := mustParseCert(t, leafCertPEM) + + conf := config.RootSection("fftls_metrics_client") + InitTLSConfig(conf) + conf.Set(HTTPConfTLSEnabled, true) + conf.Set(HTTPConfTLSCAFile, caBundleFile) + conf.Set(HTTPConfTLSCertFile, leafCertFile) + conf.Set(HTTPConfTLSKeyFile, leafKeyFile) + + _, err := ConstructTLSConfig(context.Background(), conf, ClientType) + require.NoError(t, err) + + // Each certificate in the CA bundle gets its own series under the type="ca" label + caAVal, ok := findCertExpiry(t, mr, "ca", "ca-a") + require.True(t, ok, "ca-a expiry gauge not found") + assert.Equal(t, float64(caA.NotAfter.Unix()), caAVal) + + caBVal, ok := findCertExpiry(t, mr, "ca", "ca-b") + require.True(t, ok, "ca-b expiry gauge not found") + assert.Equal(t, float64(caB.NotAfter.Unix()), caBVal) + + // A client TLS config records the leaf under type="client" + clientVal, ok := findCertExpiry(t, mr, "client", "leaf") + require.True(t, ok, "client expiry gauge not found") + assert.Equal(t, float64(leaf.NotAfter.Unix()), clientVal) + + // ... and nothing should have been recorded under type="server" + _, ok = findCertExpiry(t, mr, "server", "leaf") + assert.False(t, ok, "server gauge should not be set for a client config") +} + +func TestEnableCertificateMetricsRecordsServerExpiryInline(t *testing.T) { + mr := enableTestCertMetrics(t, "test_fftls_server") + + caA := mustParseCert(t, caACertPEM) + leaf := mustParseCert(t, leafCertPEM) + + // Inline (non-file) PEM material is recorded the same way + conf := &Config{ + Enabled: true, + CA: string(caACertPEM), + Cert: string(leafCertPEM), + Key: string(leafKeyPEM), + } + _, err := NewTLSConfig(context.Background(), conf, ServerType) + require.NoError(t, err) + + caAVal, ok := findCertExpiry(t, mr, "ca", "ca-a") + require.True(t, ok, "ca-a expiry gauge not found") + assert.Equal(t, float64(caA.NotAfter.Unix()), caAVal) + + // A server TLS config records the leaf under type="server" + serverVal, ok := findCertExpiry(t, mr, "server", "leaf") + require.True(t, ok, "server expiry gauge not found") + assert.Equal(t, float64(leaf.NotAfter.Unix()), serverVal) + + _, ok = findCertExpiry(t, mr, "client", "leaf") + assert.False(t, ok, "client gauge should not be set for a server config") +} + +func TestEnableCertificateMetricsIdempotent(t *testing.T) { + certMetricsManager = nil + t.Cleanup(func() { certMetricsManager = nil }) + ctx := context.Background() + mr := metric.NewPrometheusMetricsRegistry("test_fftls_idem") + require.NoError(t, EnableCertificateMetrics(ctx, mr)) + // Second call is a no-op and must not error (would otherwise fail re-registering the subsystem) + require.NoError(t, EnableCertificateMetrics(ctx, mr)) +} + +func TestEnableCertificateMetricsError(t *testing.T) { + certMetricsManager = nil + t.Cleanup(func() { certMetricsManager = nil }) + ctx := context.Background() + mr := metric.NewPrometheusMetricsRegistry("test_fftls_err") + // Claim the "tls" subsystem before fftls can, forcing a registration error + _, _ = mr.NewMetricsManagerForSubsystem(ctx, CertMetricsSubsystem) + err := EnableCertificateMetrics(ctx, mr) + assert.Error(t, err) +} + +func TestNewTLSConfigNoMetricsManagerNoPanic(t *testing.T) { + // With metrics disabled (the default), loading certs must not panic + certMetricsManager = nil + dir := t.TempDir() + caFile := writeTemp(t, dir, "ca-a-cert.pem", caACertPEM) + + conf := config.RootSection("fftls_no_metrics") + InitTLSConfig(conf) + conf.Set(HTTPConfTLSEnabled, true) + conf.Set(HTTPConfTLSCAFile, caFile) + + tlsConfig, err := ConstructTLSConfig(context.Background(), conf, ClientType) + require.NoError(t, err) + assert.NotNil(t, tlsConfig) +} + +// secondCertInBundle returns the PEM bytes of the second certificate in a bundle, for parsing. +func secondCertInBundle(t *testing.T, bundlePEM []byte) []byte { + _, rest := pem.Decode(bundlePEM) + require.NotEmpty(t, rest) + return rest +} diff --git a/pkg/fftls/fftls.go b/pkg/fftls/fftls.go index abcafcc..45fa513 100644 --- a/pkg/fftls/fftls.go +++ b/pkg/fftls/fftls.go @@ -21,6 +21,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/pem" "os" "regexp" "strings" @@ -28,6 +29,7 @@ import ( "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/i18n" "github.com/hyperledger/firefly-common/pkg/log" + "github.com/hyperledger/firefly-common/pkg/metric" ) type TLSType string @@ -37,6 +39,45 @@ const ( ClientType TLSType = "client" ) +const ( + // CertMetricsSubsystem is the metrics subsystem under which the certificate expiry gauge is + // registered, i.e. ff_tls_certificate_expiry. + CertMetricsSubsystem = "tls" + + metricsTLSCertificateExpiry = "certificate_expiry" + + // Values for the "type" label on the certificate expiry gauge + certTypeCA = "ca" + certTypeClient = "client" + certTypeServer = "server" +) + +// certMetricsManager is the optional, process-wide metrics manager used to emit certificate expiry +// gauges as TLS configs are loaded. It is nil until EnableCertificateMetrics is called. +var certMetricsManager metric.MetricsManager + +// EnableCertificateMetrics registers a gauge (in the "tls" subsystem) that is set to the unix +// timestamp at which loaded TLS certificates expire. Once enabled, every subsequent NewTLSConfig / +// ConstructTLSConfig call records the gauge for each CA certificate, and for the configured client or +// server certificate - distinguished by the "type" label (ca/client/server). Because certificate +// expiry is static, these are only recorded once when the certificate material is read - never +// per-connection. +// +// It is safe to call this multiple times; only the first call registers the metric. Callers that +// build both client and server TLS configs only need to call it once for the shared registry. +func EnableCertificateMetrics(ctx context.Context, metricsRegistry metric.MetricsRegistry) error { + if certMetricsManager != nil { + return nil + } + mm, err := metricsRegistry.NewMetricsManagerForSubsystem(ctx, CertMetricsSubsystem) + if err != nil { + return err + } + mm.NewGaugeMetricWithLabels(ctx, metricsTLSCertificateExpiry, "TLS certificate expiry as a unix timestamp", []string{"type", "subject", "issuer"}, false) + certMetricsManager = mm + return nil +} + func ConstructTLSConfig(ctx context.Context, conf config.Section, tlsType TLSType) (*tls.Config, error) { return NewTLSConfig(ctx, GenerateConfig(conf), tlsType) } @@ -72,6 +113,9 @@ func NewTLSConfig(ctx context.Context, config *Config, tlsType TLSType) (*tls.Co ok := rootCAs.AppendCertsFromPEM(caBytes) if !ok { err = i18n.NewError(ctx, i18n.MsgInvalidCAFile) + } else { + // The CA bundle may contain multiple certificates - record an expiry gauge for each + recordCACertExpiryMetrics(ctx, caBytes) } } case config.CA != "": @@ -79,6 +123,9 @@ func NewTLSConfig(ctx context.Context, config *Config, tlsType TLSType) (*tls.Co ok := rootCAs.AppendCertsFromPEM([]byte(config.CA)) if !ok { err = i18n.NewError(ctx, i18n.MsgInvalidCAFile) + } else { + // The CA bundle may contain multiple certificates - record an expiry gauge for each + recordCACertExpiryMetrics(ctx, []byte(config.CA)) } default: rootCAs, err = x509.SystemCertPool() @@ -109,6 +156,10 @@ func NewTLSConfig(ctx context.Context, config *Config, tlsType TLSType) (*tls.Co } if configuredCert != nil { + // Record an expiry gauge for the configured leaf certificate (client cert for ClientType, + // server cert for ServerType). The corresponding key must also have been provided to reach here. + recordLeafCertExpiryMetric(ctx, configuredCert, tlsType) + // Rather than letting Golang pick a certificate it thinks matches from the list of one, // we directly supply it the one we have in all cases. tlsConfig.GetClientCertificate = func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) { @@ -146,6 +197,65 @@ func NewTLSConfig(ctx context.Context, config *Config, tlsType TLSType) (*tls.Co } +// recordCACertExpiryMetrics decodes a PEM bundle (which may contain one or more certificates) and +// records a CA certificate expiry gauge for each. It is best-effort and never fails the TLS config +// construction: it is a no-op if metrics are not enabled, and certificates that cannot be parsed are +// skipped with a warning. +func recordCACertExpiryMetrics(ctx context.Context, pemBytes []byte) { + if certMetricsManager == nil { + return + } + rest := pemBytes + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + log.L(ctx).Warnf("Skipping certificate that could not be parsed for expiry metric: %s", err) + continue + } + setCertExpiryGauge(ctx, certTypeCA, cert) + } +} + +// recordLeafCertExpiryMetric records the expiry gauge for the leaf certificate of a configured key +// pair - using the client gauge for ClientType TLS and the server gauge for ServerType TLS. +func recordLeafCertExpiryMetric(ctx context.Context, cert *tls.Certificate, tlsType TLSType) { + if certMetricsManager == nil || len(cert.Certificate) == 0 { + return + } + leaf := cert.Leaf + if leaf == nil { + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + log.L(ctx).Warnf("Unable to parse leaf certificate for expiry metric: %s", err) + return + } + leaf = parsed + } + certType := certTypeClient + if tlsType == ServerType { + certType = certTypeServer + } + setCertExpiryGauge(ctx, certType, leaf) +} + +// setCertExpiryGauge sets the certificate expiry gauge to the unix timestamp of the certificate's +// expiry, labelled with the certificate type (ca/client/server). +func setCertExpiryGauge(ctx context.Context, certType string, cert *x509.Certificate) { + certMetricsManager.SetGaugeMetricWithLabels(ctx, metricsTLSCertificateExpiry, float64(cert.NotAfter.Unix()), map[string]string{ + "type": certType, + "subject": cert.Subject.String(), + "issuer": cert.Issuer.String(), + }, nil) +} + var SubjectDNKnownAttributes = map[string]func(pkix.Name) []string{ "C": func(n pkix.Name) []string { return n.Country diff --git a/pkg/fftls/testdata/ca-a-cert.pem b/pkg/fftls/testdata/ca-a-cert.pem new file mode 100644 index 0000000..7510a6e --- /dev/null +++ b/pkg/fftls/testdata/ca-a-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUfUmbqnf6PZ7EhgZPujfRp9V6AogwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYTEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDNaFw0zNjA2MDYxMzA3NDNaMCExDTALBgNVBAMMBGNhLWExEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCupW7v2AdA +tcfCiK+Wm3a75WE8LgSJNchbKBpotqZ22xRomdREH7e39GiE9Kj6FDKBWptJQje0 +33bWwjoB5Zd5wmXcxq0uhst88c+DBwJ6t8SoSSnt5uCRZ0neHdGSvNFSP2yUHE9/ +1r94ZNMfKvGSyKbvL8WBqIEhMudTkGjkXn5GKCmGYCtXM7vmQV+kbqK5x/mcMEwj +xBXzfg3g5wAHmwciEiMUelBE5FLCHD7tuMcN+QrA0+oq37SgA3BXdHVJGzvAvvEw +sjtzabv2Op0pmYk4jWXrLCRrGbVTjJlhU7ZpMFQvNSLciYTk0dtRtuA1zJU7VtfC +dAU7d5qRBWwXAgMBAAGjZDBiMB0GA1UdDgQWBBTcOiO/3zPacrsEyQmcXQTPfmuM +sDAfBgNVHSMEGDAWgBTcOiO/3zPacrsEyQmcXQTPfmuMsDAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAHhRD+IkWHhh +qk933B/of/wi6BXhTGWUVF10167UaiKmgOa3IfXXZCYoP0zSUHfEi2nXWZdw+1FJ +2+J+yH4vALIHSbNLU5et2WwJEQn94Stfsu1yvzL26zcGlFFakYTI4ndC00M9GIYL +PELpMlymoQH9nY/297WiXQ9fUfB4KoNqCB4kV7MEaeeZGou5RIpfGQluRMbCmskh +aaJwQONjFjr1iAyIvbgn0G/Yo0cemt3AbSSsob1OcHCjFy8l9auNt0yKpGA+ZhzM +YY/IkN/5oUfQHXgKLSRYq3++7cIUaIdU9oBFjJP6jS/sWHEvxjO0a/ti/q4cwc+F +8zTwc2S45Po= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/ca-b-cert.pem b/pkg/fftls/testdata/ca-b-cert.pem new file mode 100644 index 0000000..ea3b84f --- /dev/null +++ b/pkg/fftls/testdata/ca-b-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUW9DuVZAuPaofedNa3DOi527uvVEwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDRaFw0zNjA2MDYxMzA3NDRaMCExDTALBgNVBAMMBGNhLWIxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCv20fYBlUp +fa/fj3JfMf9auGsoKq/+CPRza0ZZrZ8qY5+Idh5/cJkPuY7N2ipP3Wljo+49dSpu +CmF/ac2JIFYmJMyyolFZTHsR7WVeaEWC/u+/gY9IM9K2qXkgF9rZGVSUJ3sPO8yA +QV8x5VrAUGDxqVKvswwORZCVGXqRfGyb57PnxS9zAkvsfuMGsFAhcY6gK3QHrH3o +LEzs8KkfuCZO0wtxuuV+vrwPUUtJ3c7A9DjT1WkOhDZdDvvN7YUczKafQpLqgVcb +ARc9v9nRNOCdPdxL8O4ylO5nNeh4nriQJVEy/pZcsVDw/EGk3otm9vSkY6TOqp2P +vqXfWleXCr7xAgMBAAGjZDBiMB0GA1UdDgQWBBR8eVe2ZVlqNGnlDqPATlemtxqc +bjAfBgNVHSMEGDAWgBR8eVe2ZVlqNGnlDqPATlemtxqcbjAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAJHqec/bxjqk +5VLFcYX+1SnUIt8fXB3qN7B8GWH35ol0CSwEkuztdiaAU1FnKRpqD+H4/d8Fejs3 +h4dXuVGGu2OM6zl/NJfn6YhVqVJa6H3sW+vO/SdAsNC+CCu8+5E3p71UhwDtHTZ4 +kZi89j6zTzenbz0M27UkuiIJP5W51C1qSQ+yKqxESzAlF4UNq4eP7bF2g1p09MMc +ob/SbQ4PonUL0XFqYcaaJJVDiRGWvmjOWr/ubkc1DJ3mjv9yAZL7uhSolHqadxXe +XqIhHJdXdDbHpSRFjvP1cEHPun18Bz6kMMgBOWOoIruai1ZSMnR8pFeYN6rGgPDh +euFg46XnTNc= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/ca-bundle.pem b/pkg/fftls/testdata/ca-bundle.pem new file mode 100644 index 0000000..f36ec09 --- /dev/null +++ b/pkg/fftls/testdata/ca-bundle.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUfUmbqnf6PZ7EhgZPujfRp9V6AogwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYTEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDNaFw0zNjA2MDYxMzA3NDNaMCExDTALBgNVBAMMBGNhLWExEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCupW7v2AdA +tcfCiK+Wm3a75WE8LgSJNchbKBpotqZ22xRomdREH7e39GiE9Kj6FDKBWptJQje0 +33bWwjoB5Zd5wmXcxq0uhst88c+DBwJ6t8SoSSnt5uCRZ0neHdGSvNFSP2yUHE9/ +1r94ZNMfKvGSyKbvL8WBqIEhMudTkGjkXn5GKCmGYCtXM7vmQV+kbqK5x/mcMEwj +xBXzfg3g5wAHmwciEiMUelBE5FLCHD7tuMcN+QrA0+oq37SgA3BXdHVJGzvAvvEw +sjtzabv2Op0pmYk4jWXrLCRrGbVTjJlhU7ZpMFQvNSLciYTk0dtRtuA1zJU7VtfC +dAU7d5qRBWwXAgMBAAGjZDBiMB0GA1UdDgQWBBTcOiO/3zPacrsEyQmcXQTPfmuM +sDAfBgNVHSMEGDAWgBTcOiO/3zPacrsEyQmcXQTPfmuMsDAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAHhRD+IkWHhh +qk933B/of/wi6BXhTGWUVF10167UaiKmgOa3IfXXZCYoP0zSUHfEi2nXWZdw+1FJ +2+J+yH4vALIHSbNLU5et2WwJEQn94Stfsu1yvzL26zcGlFFakYTI4ndC00M9GIYL +PELpMlymoQH9nY/297WiXQ9fUfB4KoNqCB4kV7MEaeeZGou5RIpfGQluRMbCmskh +aaJwQONjFjr1iAyIvbgn0G/Yo0cemt3AbSSsob1OcHCjFy8l9auNt0yKpGA+ZhzM +YY/IkN/5oUfQHXgKLSRYq3++7cIUaIdU9oBFjJP6jS/sWHEvxjO0a/ti/q4cwc+F +8zTwc2S45Po= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUW9DuVZAuPaofedNa3DOi527uvVEwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDRaFw0zNjA2MDYxMzA3NDRaMCExDTALBgNVBAMMBGNhLWIxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCv20fYBlUp +fa/fj3JfMf9auGsoKq/+CPRza0ZZrZ8qY5+Idh5/cJkPuY7N2ipP3Wljo+49dSpu +CmF/ac2JIFYmJMyyolFZTHsR7WVeaEWC/u+/gY9IM9K2qXkgF9rZGVSUJ3sPO8yA +QV8x5VrAUGDxqVKvswwORZCVGXqRfGyb57PnxS9zAkvsfuMGsFAhcY6gK3QHrH3o +LEzs8KkfuCZO0wtxuuV+vrwPUUtJ3c7A9DjT1WkOhDZdDvvN7YUczKafQpLqgVcb +ARc9v9nRNOCdPdxL8O4ylO5nNeh4nriQJVEy/pZcsVDw/EGk3otm9vSkY6TOqp2P +vqXfWleXCr7xAgMBAAGjZDBiMB0GA1UdDgQWBBR8eVe2ZVlqNGnlDqPATlemtxqc +bjAfBgNVHSMEGDAWgBR8eVe2ZVlqNGnlDqPATlemtxqcbjAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAJHqec/bxjqk +5VLFcYX+1SnUIt8fXB3qN7B8GWH35ol0CSwEkuztdiaAU1FnKRpqD+H4/d8Fejs3 +h4dXuVGGu2OM6zl/NJfn6YhVqVJa6H3sW+vO/SdAsNC+CCu8+5E3p71UhwDtHTZ4 +kZi89j6zTzenbz0M27UkuiIJP5W51C1qSQ+yKqxESzAlF4UNq4eP7bF2g1p09MMc +ob/SbQ4PonUL0XFqYcaaJJVDiRGWvmjOWr/ubkc1DJ3mjv9yAZL7uhSolHqadxXe +XqIhHJdXdDbHpSRFjvP1cEHPun18Bz6kMMgBOWOoIruai1ZSMnR8pFeYN6rGgPDh +euFg46XnTNc= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/leaf-cert.pem b/pkg/fftls/testdata/leaf-cert.pem new file mode 100644 index 0000000..6654056 --- /dev/null +++ b/pkg/fftls/testdata/leaf-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUC2TiVvG2jp1aBQ46tR1y+b23GaYwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEbGVhZjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDRaFw0zNjA2MDYxMzA3NDRaMCExDTALBgNVBAMMBGxlYWYxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCn5fq/txDs +Cz04zKIE7ofXmCz51tnnN33f6qCXxZdfmU7YhuaaCIkPV3H4uY3klKjGBW765UUz +o7+iwnleqkbAeTYuQWNpn/sQN9aU0L1qI+Lod5YfzRHwwmT47r8B6IBT9LZ6zESD +rVRe+SQh5FFMyDBIXGuYRQLl/s0u1FFsRscZxo2jOuFH8M6kDk6TP8+cu0RDumMR +Hoqa/hRFJAUhODs6sFvvajvta70Rkl3Jb+wHSz/vwSrIPVurPsfAaMZWL3p0PPb3 +Z5KFQWalQ4Y2p9LrQm28c8+bXQiY0Hgzr3asgRBL4figpYEU9XQPV2C7kwsa7sIf +Y019NJ1xLLhZAgMBAAGjZDBiMB0GA1UdDgQWBBQ/Y+IBfMyjUnobpbePlMSFF/YB +vzAfBgNVHSMEGDAWgBQ/Y+IBfMyjUnobpbePlMSFF/YBvzAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAC/gnJoOMMpK +8zdA+Uv3E5qSrX8IQPR8lSZPsccSQgRYbh2bFr7+aCiV9rN70s50iT+WtZFce3mh +Uomc5/yGs8nd8CrxDSSaHWczvrNZDc30J8CMHzt1p5k+oVFu66G7DY70N4ovt20b +4u7Hcx6r7KotSpmvALFiT9hbmWJvWwGQaFjaVx8Nq4ZwKP7fvpBV9KNnr84TJePT +ImbULPdgknKJhuovd235zH7BNNxcEDM4hVQWBEYh+GKqf1RZVrpYY1CiJsC07kak +9uX8MC2cPyg6HbYKcEuWi42CBhkb9OIURDe3pozfei2n11MJs06k7J3FD4k04e6w +8GvtiCi9yAw= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/leaf-key.pem b/pkg/fftls/testdata/leaf-key.pem new file mode 100644 index 0000000..a3e6988 --- /dev/null +++ b/pkg/fftls/testdata/leaf-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCn5fq/txDsCz04 +zKIE7ofXmCz51tnnN33f6qCXxZdfmU7YhuaaCIkPV3H4uY3klKjGBW765UUzo7+i +wnleqkbAeTYuQWNpn/sQN9aU0L1qI+Lod5YfzRHwwmT47r8B6IBT9LZ6zESDrVRe ++SQh5FFMyDBIXGuYRQLl/s0u1FFsRscZxo2jOuFH8M6kDk6TP8+cu0RDumMRHoqa +/hRFJAUhODs6sFvvajvta70Rkl3Jb+wHSz/vwSrIPVurPsfAaMZWL3p0PPb3Z5KF +QWalQ4Y2p9LrQm28c8+bXQiY0Hgzr3asgRBL4figpYEU9XQPV2C7kwsa7sIfY019 +NJ1xLLhZAgMBAAECggEADrPh+GXkkncNfmrVCU+GD8ggsCQzYr8GQ9R9Ca1g6xs2 +v1lY2kZDElfbACfnmAu4l9aj+E6Zd8MwJeWX0UVh+rrilrxdI0PCLZJmNRPuclwA +I9FNES3ZE1dCllX53gXpLvDwBeLbYRiNmd+BXYJr6ChjqgKF3Yi1j0R33VD0bGHG +ADfYg3OtPcK1ojLY7hrTpg7MeTyB+3q37zzx8z+7YgcPE2AHlZrxnAbqzUd6ai0z +QiIvFsGKPod3JLKOMmUMazwgfh+WtKJssbs/opmA1CyHkjN1HE2eTNcI+iAd7DFM +j+2yo8qK3yS/zMLVCu2BJwX3aqsbldDNvHbQRkzVvQKBgQDPpwoCHQrG3sF23u3o +JW0sc0QNu75C0zgIOLpsj/Medebe8BjvtqFdpUN+ExcqwwQAYuzNQAerenymq899 +wX56LnzLgzpFfmq32/Z6Izk1KUuXmocsmaWe7xifoVnG+4hLdGyCbeB5x6modnhc +s77Kvyv6rCucCBWFPKnJ/zwgtwKBgQDO/W0+4D241fBt5jQcVroouIAnw8JZm2kC +2qoFGzi5RRpx0TYAsMENbs7ccsJn5l3CuPUqViAjXY5+ew3qoMiJyhaGsdfgym82 +Rvsx1YnfrOdfXrE0NejLK9+EWSYhNsKiqqoNPkKAx5qa8wkTtfIQaKid8U3GuXpR +8TTVzd6/bwKBgFqPuReu3tJYt/tFwnAqUnC+XIG4zYOLZrZ/Nx69sQQp22SDRfzV +o46Z0tObM9Fcd9RLUIc7U5z/sHloGPf5eVWEDL0dID7KoXRKJDakQgDjX6pgpG9Z +GxgzooOL9QGJFaoCvogrr2itIVrtQBT990mpEl8i02obRHd1O39zJyAFAoGBAM55 +omYcDbW5Q1J9VfIG2UARe1UvQ0lba077jKn3U4gMVKrAhNv/dTPPGu5wU1iNRW4e +TgDjVL+ybZfhbCpmJGS+f+XwtWV4yaMc/yng1t2Wfl51+NIaDJPOufItc4miNFlL +YMJBHtmTQrfaQ220CzkvDTQwJuNa1Zezwn6cE9xlAoGAQ61T/GYB38FFaz5lp0yV +7wV2xYfSdfS1fGVVSnFQsVUD1lI4BCd4yL2trHB/DV+4OewvD+JszDDWv3pD3rBp +BXFA9XpAhzcpJNuLFWirmCXH23tSEw7CNJEhtU6jo2oPSJBD5w6xhTISyVq1r2pr +qf1hKrlOg432GEok+w/Vax4= +-----END PRIVATE KEY----- From e91681d9b1d16c7eb17ef3fc37829ac44dc96afa Mon Sep 17 00:00:00 2001 From: hfuss Date: Tue, 9 Jun 2026 14:53:16 -0400 Subject: [PATCH 02/10] make tests with static expected expiries; ensure CA and leaf bundles work as expected - CA bundles make metrics for all certs, leaf only the leaf w/ a key makes a metric Signed-off-by: hfuss --- pkg/fftls/certexpiry_test.go | 83 +++++++++++++++++++----------- pkg/fftls/fftls.go | 5 ++ pkg/fftls/testdata/leaf-bundle.pem | 40 ++++++++++++++ pkg/fftls/testdata/leaf-cert.pem | 36 ++++++------- pkg/fftls/testdata/leaf-key.pem | 52 +++++++++---------- 5 files changed, 142 insertions(+), 74 deletions(-) create mode 100644 pkg/fftls/testdata/leaf-bundle.pem diff --git a/pkg/fftls/certexpiry_test.go b/pkg/fftls/certexpiry_test.go index 49fe9bd..eb7efd3 100644 --- a/pkg/fftls/certexpiry_test.go +++ b/pkg/fftls/certexpiry_test.go @@ -18,9 +18,7 @@ package fftls import ( "context" - "crypto/x509" _ "embed" - "encoding/pem" "os" "path/filepath" "strings" @@ -32,12 +30,21 @@ import ( "github.com/stretchr/testify/require" ) -// The certificates below are static self-signed fixtures generated once with openssl, e.g.: +// The certificates below are static self-signed fixtures generated once with openssl. The CA +// fixtures are long-lived (valid until 2036); the leaf is deliberately generated to expire on +// 2026-06-10, i.e. it is already expired. This proves we record the expiry of an expired cert +// just the same - we only parse the NotAfter, we never validate the cert, so loading and +// recording succeed regardless. Example commands: // // openssl req -x509 -newkey rsa:2048 -keyout leaf-key.pem -out leaf-cert.pem \ -// -days 3650 -nodes -subj "/CN=leaf/O=FireFly" -addext "subjectAltName=IP:127.0.0.1" +// -not_before 20260609000000Z -not_after 20260610000000Z -nodes \ +// -subj "/CN=leaf/O=FireFly" -addext "subjectAltName=IP:127.0.0.1" // // ca-bundle.pem is the concatenation of ca-a-cert.pem and ca-b-cert.pem. +// +// The expiry timestamps below are the NotAfter (unix) of each fixture, hard-coded so the test +// asserts against a fixed expectation rather than re-deriving it from the same parsing logic +// under test. DO NOT regenerate the fixtures without also updating these constants. var ( //go:embed testdata/ca-a-cert.pem caACertPEM []byte @@ -47,15 +54,17 @@ var ( leafCertPEM []byte //go:embed testdata/leaf-key.pem leafKeyPEM []byte + // leaf-bundle.pem is leaf-cert.pem followed by ca-a-cert.pem, standing in for a leaf cert file + // that bundles the leaf with its intermediate chain. + //go:embed testdata/leaf-bundle.pem + leafBundlePEM []byte ) -func mustParseCert(t *testing.T, pemBytes []byte) *x509.Certificate { - block, _ := pem.Decode(pemBytes) - require.NotNil(t, block) - cert, err := x509.ParseCertificate(block.Bytes) - require.NoError(t, err) - return cert -} +const ( + caAExpiryUnix = float64(2096370463) // CN=ca-a, notAfter=2036-06-06 13:07:43Z + caBExpiryUnix = float64(2096370464) // CN=ca-b, notAfter=2036-06-06 13:07:44Z + leafExpiryUnix = float64(1781049600) // CN=leaf, notAfter=2026-06-10 00:00:00Z (already expired) +) func writeTemp(t *testing.T, dir, name string, content []byte) string { p := filepath.Join(dir, name) @@ -108,10 +117,6 @@ func TestEnableCertificateMetricsRecordsCAAndClientExpiry(t *testing.T) { leafCertFile := writeTemp(t, dir, "leaf-cert.pem", leafCertPEM) leafKeyFile := writeTemp(t, dir, "leaf-key.pem", leafKeyPEM) - caA := mustParseCert(t, caACertPEM) - caB := mustParseCert(t, secondCertInBundle(t, caBundlePEM)) - leaf := mustParseCert(t, leafCertPEM) - conf := config.RootSection("fftls_metrics_client") InitTLSConfig(conf) conf.Set(HTTPConfTLSEnabled, true) @@ -125,28 +130,53 @@ func TestEnableCertificateMetricsRecordsCAAndClientExpiry(t *testing.T) { // Each certificate in the CA bundle gets its own series under the type="ca" label caAVal, ok := findCertExpiry(t, mr, "ca", "ca-a") require.True(t, ok, "ca-a expiry gauge not found") - assert.Equal(t, float64(caA.NotAfter.Unix()), caAVal) + assert.Equal(t, caAExpiryUnix, caAVal) caBVal, ok := findCertExpiry(t, mr, "ca", "ca-b") require.True(t, ok, "ca-b expiry gauge not found") - assert.Equal(t, float64(caB.NotAfter.Unix()), caBVal) + assert.Equal(t, caBExpiryUnix, caBVal) // A client TLS config records the leaf under type="client" clientVal, ok := findCertExpiry(t, mr, "client", "leaf") require.True(t, ok, "client expiry gauge not found") - assert.Equal(t, float64(leaf.NotAfter.Unix()), clientVal) + assert.Equal(t, leafExpiryUnix, clientVal) // ... and nothing should have been recorded under type="server" _, ok = findCertExpiry(t, mr, "server", "leaf") assert.False(t, ok, "server gauge should not be set for a client config") } +func TestEnableCertificateMetricsBundledLeafRecordsLeafNotChain(t *testing.T) { + mr := enableTestCertMetrics(t, "test_fftls_leaf_bundle") + + dir := t.TempDir() + // The cert file bundles the leaf with an intermediate (ca-a). We must record the leaf's expiry, + // not the intermediate's. + leafCertFile := writeTemp(t, dir, "leaf-bundle.pem", leafBundlePEM) + leafKeyFile := writeTemp(t, dir, "leaf-key.pem", leafKeyPEM) + + conf := config.RootSection("fftls_metrics_leaf_bundle") + InitTLSConfig(conf) + conf.Set(HTTPConfTLSEnabled, true) + conf.Set(HTTPConfTLSCertFile, leafCertFile) + conf.Set(HTTPConfTLSKeyFile, leafKeyFile) + + _, err := ConstructTLSConfig(context.Background(), conf, ClientType) + require.NoError(t, err) + + // The recorded client expiry is the leaf's, even though ca-a appears later in the bundle + clientVal, ok := findCertExpiry(t, mr, "client", "leaf") + require.True(t, ok, "client expiry gauge not found") + assert.Equal(t, leafExpiryUnix, clientVal) + + // The intermediate in the cert bundle must not be recorded as a client (or ca) series here + _, ok = findCertExpiry(t, mr, "client", "ca-a") + assert.False(t, ok, "intermediate from the leaf bundle should not be recorded") +} + func TestEnableCertificateMetricsRecordsServerExpiryInline(t *testing.T) { mr := enableTestCertMetrics(t, "test_fftls_server") - caA := mustParseCert(t, caACertPEM) - leaf := mustParseCert(t, leafCertPEM) - // Inline (non-file) PEM material is recorded the same way conf := &Config{ Enabled: true, @@ -159,12 +189,12 @@ func TestEnableCertificateMetricsRecordsServerExpiryInline(t *testing.T) { caAVal, ok := findCertExpiry(t, mr, "ca", "ca-a") require.True(t, ok, "ca-a expiry gauge not found") - assert.Equal(t, float64(caA.NotAfter.Unix()), caAVal) + assert.Equal(t, caAExpiryUnix, caAVal) // A server TLS config records the leaf under type="server" serverVal, ok := findCertExpiry(t, mr, "server", "leaf") require.True(t, ok, "server expiry gauge not found") - assert.Equal(t, float64(leaf.NotAfter.Unix()), serverVal) + assert.Equal(t, leafExpiryUnix, serverVal) _, ok = findCertExpiry(t, mr, "client", "leaf") assert.False(t, ok, "client gauge should not be set for a server config") @@ -206,10 +236,3 @@ func TestNewTLSConfigNoMetricsManagerNoPanic(t *testing.T) { require.NoError(t, err) assert.NotNil(t, tlsConfig) } - -// secondCertInBundle returns the PEM bytes of the second certificate in a bundle, for parsing. -func secondCertInBundle(t *testing.T, bundlePEM []byte) []byte { - _, rest := pem.Decode(bundlePEM) - require.NotEmpty(t, rest) - return rest -} diff --git a/pkg/fftls/fftls.go b/pkg/fftls/fftls.go index 45fa513..034b7e1 100644 --- a/pkg/fftls/fftls.go +++ b/pkg/fftls/fftls.go @@ -226,6 +226,11 @@ func recordCACertExpiryMetrics(ctx context.Context, pemBytes []byte) { // recordLeafCertExpiryMetric records the expiry gauge for the leaf certificate of a configured key // pair - using the client gauge for ClientType TLS and the server gauge for ServerType TLS. +// +// The configured cert/key may be a bundle (leaf followed by its intermediate chain). crypto/tls +// guarantees the leaf is first: X509KeyPair/LoadX509KeyPair require Certificate[0] to be the +// certificate that matches the private key, so we record the expiry of Certificate[0] - never an +// intermediate from the chain. func recordLeafCertExpiryMetric(ctx context.Context, cert *tls.Certificate, tlsType TLSType) { if certMetricsManager == nil || len(cert.Certificate) == 0 { return diff --git a/pkg/fftls/testdata/leaf-bundle.pem b/pkg/fftls/testdata/leaf-bundle.pem new file mode 100644 index 0000000..c9fdfbc --- /dev/null +++ b/pkg/fftls/testdata/leaf-bundle.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUYxML980GdHFqGitUAPHINb3GkbwwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEbGVhZjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkw +MDAwMDBaFw0yNjA2MTAwMDAwMDBaMCExDTALBgNVBAMMBGxlYWYxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDPhp081QNR +SohFXbogM1+qoEuN+bbkKUjrRSZ3r4JK+S86b6tZPU+qoLWc0A8n1luZ7WZva0QB +yO8684W6Vf9vxQzKA/l3yHzFx20cEHtGRBYkJuZyzv7Xe9NTHSjvR6LiWz/xBR24 +vbNnHMtCNpvcv31q4h7RDDX6ifrxB5jKHSUFexO/D0UeAbgALjtI8gSdNXJ+QS65 +ATEyww9S9wJJgEGdeMvHJwWq7tCmRBXjQDWCzqwxafX8V97+VWh5Wz9v5eYxM0cP +0tvh3l1Ka/Vu8h/O7VzXZ9quzJlNfkPE5ZBwjp0hbedu9UiNaszcBOp3QPB4QkrH +Pc4Sb2oKz10XAgMBAAGjZDBiMB0GA1UdDgQWBBTyL6MXE14N6vO8xpEz7NCCxPdJ +1TAfBgNVHSMEGDAWgBTyL6MXE14N6vO8xpEz7NCCxPdJ1TAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIYvN8yTffAl +ww+stDY5osAPMl0wymg0SV4qsJ/yhEFk652WXEPBi9iV5V893tzQ34pDU60/8qmd +BIYr1PDNYaJxBzAc/JA8/8XZVBitt0bAtuvLWhqD2X644zbiI1Dtz7IEOCzPKy3g +zt6xAmXZul7ZyXRdQdYktu7O7PlN+0I9texGSY/T1125YXq+GCtHG5LTz3FCAHOH +b+BRVVANwv+VWCFAUHJMRuAdv661LOuC8kK453ia9hTnFq04mfxf+bjeOYAvHnN/ +SRZop8ROJwEHSd/coRHJlnNqfeNDlGSzZeISkaQfl7X6LXqQwxMx35NDQdTugbBF +KRzI+gXOZ6o= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUfUmbqnf6PZ7EhgZPujfRp9V6AogwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYTEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDNaFw0zNjA2MDYxMzA3NDNaMCExDTALBgNVBAMMBGNhLWExEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCupW7v2AdA +tcfCiK+Wm3a75WE8LgSJNchbKBpotqZ22xRomdREH7e39GiE9Kj6FDKBWptJQje0 +33bWwjoB5Zd5wmXcxq0uhst88c+DBwJ6t8SoSSnt5uCRZ0neHdGSvNFSP2yUHE9/ +1r94ZNMfKvGSyKbvL8WBqIEhMudTkGjkXn5GKCmGYCtXM7vmQV+kbqK5x/mcMEwj +xBXzfg3g5wAHmwciEiMUelBE5FLCHD7tuMcN+QrA0+oq37SgA3BXdHVJGzvAvvEw +sjtzabv2Op0pmYk4jWXrLCRrGbVTjJlhU7ZpMFQvNSLciYTk0dtRtuA1zJU7VtfC +dAU7d5qRBWwXAgMBAAGjZDBiMB0GA1UdDgQWBBTcOiO/3zPacrsEyQmcXQTPfmuM +sDAfBgNVHSMEGDAWgBTcOiO/3zPacrsEyQmcXQTPfmuMsDAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAHhRD+IkWHhh +qk933B/of/wi6BXhTGWUVF10167UaiKmgOa3IfXXZCYoP0zSUHfEi2nXWZdw+1FJ +2+J+yH4vALIHSbNLU5et2WwJEQn94Stfsu1yvzL26zcGlFFakYTI4ndC00M9GIYL +PELpMlymoQH9nY/297WiXQ9fUfB4KoNqCB4kV7MEaeeZGou5RIpfGQluRMbCmskh +aaJwQONjFjr1iAyIvbgn0G/Yo0cemt3AbSSsob1OcHCjFy8l9auNt0yKpGA+ZhzM +YY/IkN/5oUfQHXgKLSRYq3++7cIUaIdU9oBFjJP6jS/sWHEvxjO0a/ti/q4cwc+F +8zTwc2S45Po= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/leaf-cert.pem b/pkg/fftls/testdata/leaf-cert.pem index 6654056..cff8670 100644 --- a/pkg/fftls/testdata/leaf-cert.pem +++ b/pkg/fftls/testdata/leaf-cert.pem @@ -1,20 +1,20 @@ -----BEGIN CERTIFICATE----- -MIIDNDCCAhygAwIBAgIUC2TiVvG2jp1aBQ46tR1y+b23GaYwDQYJKoZIhvcNAQEL -BQAwITENMAsGA1UEAwwEbGVhZjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx -MzA3NDRaFw0zNjA2MDYxMzA3NDRaMCExDTALBgNVBAMMBGxlYWYxEDAOBgNVBAoM -B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCn5fq/txDs -Cz04zKIE7ofXmCz51tnnN33f6qCXxZdfmU7YhuaaCIkPV3H4uY3klKjGBW765UUz -o7+iwnleqkbAeTYuQWNpn/sQN9aU0L1qI+Lod5YfzRHwwmT47r8B6IBT9LZ6zESD -rVRe+SQh5FFMyDBIXGuYRQLl/s0u1FFsRscZxo2jOuFH8M6kDk6TP8+cu0RDumMR -Hoqa/hRFJAUhODs6sFvvajvta70Rkl3Jb+wHSz/vwSrIPVurPsfAaMZWL3p0PPb3 -Z5KFQWalQ4Y2p9LrQm28c8+bXQiY0Hgzr3asgRBL4figpYEU9XQPV2C7kwsa7sIf -Y019NJ1xLLhZAgMBAAGjZDBiMB0GA1UdDgQWBBQ/Y+IBfMyjUnobpbePlMSFF/YB -vzAfBgNVHSMEGDAWgBQ/Y+IBfMyjUnobpbePlMSFF/YBvzAPBgNVHRMBAf8EBTAD -AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAC/gnJoOMMpK -8zdA+Uv3E5qSrX8IQPR8lSZPsccSQgRYbh2bFr7+aCiV9rN70s50iT+WtZFce3mh -Uomc5/yGs8nd8CrxDSSaHWczvrNZDc30J8CMHzt1p5k+oVFu66G7DY70N4ovt20b -4u7Hcx6r7KotSpmvALFiT9hbmWJvWwGQaFjaVx8Nq4ZwKP7fvpBV9KNnr84TJePT -ImbULPdgknKJhuovd235zH7BNNxcEDM4hVQWBEYh+GKqf1RZVrpYY1CiJsC07kak -9uX8MC2cPyg6HbYKcEuWi42CBhkb9OIURDe3pozfei2n11MJs06k7J3FD4k04e6w -8GvtiCi9yAw= +MIIDNDCCAhygAwIBAgIUYxML980GdHFqGitUAPHINb3GkbwwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEbGVhZjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkw +MDAwMDBaFw0yNjA2MTAwMDAwMDBaMCExDTALBgNVBAMMBGxlYWYxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDPhp081QNR +SohFXbogM1+qoEuN+bbkKUjrRSZ3r4JK+S86b6tZPU+qoLWc0A8n1luZ7WZva0QB +yO8684W6Vf9vxQzKA/l3yHzFx20cEHtGRBYkJuZyzv7Xe9NTHSjvR6LiWz/xBR24 +vbNnHMtCNpvcv31q4h7RDDX6ifrxB5jKHSUFexO/D0UeAbgALjtI8gSdNXJ+QS65 +ATEyww9S9wJJgEGdeMvHJwWq7tCmRBXjQDWCzqwxafX8V97+VWh5Wz9v5eYxM0cP +0tvh3l1Ka/Vu8h/O7VzXZ9quzJlNfkPE5ZBwjp0hbedu9UiNaszcBOp3QPB4QkrH +Pc4Sb2oKz10XAgMBAAGjZDBiMB0GA1UdDgQWBBTyL6MXE14N6vO8xpEz7NCCxPdJ +1TAfBgNVHSMEGDAWgBTyL6MXE14N6vO8xpEz7NCCxPdJ1TAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIYvN8yTffAl +ww+stDY5osAPMl0wymg0SV4qsJ/yhEFk652WXEPBi9iV5V893tzQ34pDU60/8qmd +BIYr1PDNYaJxBzAc/JA8/8XZVBitt0bAtuvLWhqD2X644zbiI1Dtz7IEOCzPKy3g +zt6xAmXZul7ZyXRdQdYktu7O7PlN+0I9texGSY/T1125YXq+GCtHG5LTz3FCAHOH +b+BRVVANwv+VWCFAUHJMRuAdv661LOuC8kK453ia9hTnFq04mfxf+bjeOYAvHnN/ +SRZop8ROJwEHSd/coRHJlnNqfeNDlGSzZeISkaQfl7X6LXqQwxMx35NDQdTugbBF +KRzI+gXOZ6o= -----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/leaf-key.pem b/pkg/fftls/testdata/leaf-key.pem index a3e6988..8715a5b 100644 --- a/pkg/fftls/testdata/leaf-key.pem +++ b/pkg/fftls/testdata/leaf-key.pem @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCn5fq/txDsCz04 -zKIE7ofXmCz51tnnN33f6qCXxZdfmU7YhuaaCIkPV3H4uY3klKjGBW765UUzo7+i -wnleqkbAeTYuQWNpn/sQN9aU0L1qI+Lod5YfzRHwwmT47r8B6IBT9LZ6zESDrVRe -+SQh5FFMyDBIXGuYRQLl/s0u1FFsRscZxo2jOuFH8M6kDk6TP8+cu0RDumMRHoqa -/hRFJAUhODs6sFvvajvta70Rkl3Jb+wHSz/vwSrIPVurPsfAaMZWL3p0PPb3Z5KF -QWalQ4Y2p9LrQm28c8+bXQiY0Hgzr3asgRBL4figpYEU9XQPV2C7kwsa7sIfY019 -NJ1xLLhZAgMBAAECggEADrPh+GXkkncNfmrVCU+GD8ggsCQzYr8GQ9R9Ca1g6xs2 -v1lY2kZDElfbACfnmAu4l9aj+E6Zd8MwJeWX0UVh+rrilrxdI0PCLZJmNRPuclwA -I9FNES3ZE1dCllX53gXpLvDwBeLbYRiNmd+BXYJr6ChjqgKF3Yi1j0R33VD0bGHG -ADfYg3OtPcK1ojLY7hrTpg7MeTyB+3q37zzx8z+7YgcPE2AHlZrxnAbqzUd6ai0z -QiIvFsGKPod3JLKOMmUMazwgfh+WtKJssbs/opmA1CyHkjN1HE2eTNcI+iAd7DFM -j+2yo8qK3yS/zMLVCu2BJwX3aqsbldDNvHbQRkzVvQKBgQDPpwoCHQrG3sF23u3o -JW0sc0QNu75C0zgIOLpsj/Medebe8BjvtqFdpUN+ExcqwwQAYuzNQAerenymq899 -wX56LnzLgzpFfmq32/Z6Izk1KUuXmocsmaWe7xifoVnG+4hLdGyCbeB5x6modnhc -s77Kvyv6rCucCBWFPKnJ/zwgtwKBgQDO/W0+4D241fBt5jQcVroouIAnw8JZm2kC -2qoFGzi5RRpx0TYAsMENbs7ccsJn5l3CuPUqViAjXY5+ew3qoMiJyhaGsdfgym82 -Rvsx1YnfrOdfXrE0NejLK9+EWSYhNsKiqqoNPkKAx5qa8wkTtfIQaKid8U3GuXpR -8TTVzd6/bwKBgFqPuReu3tJYt/tFwnAqUnC+XIG4zYOLZrZ/Nx69sQQp22SDRfzV -o46Z0tObM9Fcd9RLUIc7U5z/sHloGPf5eVWEDL0dID7KoXRKJDakQgDjX6pgpG9Z -GxgzooOL9QGJFaoCvogrr2itIVrtQBT990mpEl8i02obRHd1O39zJyAFAoGBAM55 -omYcDbW5Q1J9VfIG2UARe1UvQ0lba077jKn3U4gMVKrAhNv/dTPPGu5wU1iNRW4e -TgDjVL+ybZfhbCpmJGS+f+XwtWV4yaMc/yng1t2Wfl51+NIaDJPOufItc4miNFlL -YMJBHtmTQrfaQ220CzkvDTQwJuNa1Zezwn6cE9xlAoGAQ61T/GYB38FFaz5lp0yV -7wV2xYfSdfS1fGVVSnFQsVUD1lI4BCd4yL2trHB/DV+4OewvD+JszDDWv3pD3rBp -BXFA9XpAhzcpJNuLFWirmCXH23tSEw7CNJEhtU6jo2oPSJBD5w6xhTISyVq1r2pr -qf1hKrlOg432GEok+w/Vax4= +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDPhp081QNRSohF +XbogM1+qoEuN+bbkKUjrRSZ3r4JK+S86b6tZPU+qoLWc0A8n1luZ7WZva0QByO86 +84W6Vf9vxQzKA/l3yHzFx20cEHtGRBYkJuZyzv7Xe9NTHSjvR6LiWz/xBR24vbNn +HMtCNpvcv31q4h7RDDX6ifrxB5jKHSUFexO/D0UeAbgALjtI8gSdNXJ+QS65ATEy +ww9S9wJJgEGdeMvHJwWq7tCmRBXjQDWCzqwxafX8V97+VWh5Wz9v5eYxM0cP0tvh +3l1Ka/Vu8h/O7VzXZ9quzJlNfkPE5ZBwjp0hbedu9UiNaszcBOp3QPB4QkrHPc4S +b2oKz10XAgMBAAECggEAAfSz1gHv8RExwu2aNnl6z6yJFG0jvczBz4MsVKPGfOxn +oeEG4rrC3cnRTF64Sy4oWNq1mhvkXTFGnUNJobLzywWMFE9V8jy6faaz2Y2Hi+b9 +Cm7aFyo/mUpPzeW6yrPdziJWskUpEuJUJtxM8h0k+j9NGvfHRehxOHZFHjEYeIx/ +a/ZXiAwfm936ITtH6w3p9YIBp6xj5aMN5387YtAOkldoPVBLf4NRqSf/o66bzF9j +WynBaPqh0v6gEFzGvfbxHi5ii2vZwJLpSA0vJ1u7+K/CF65ZsHT5or+XrphbOBDj +P6a6WESO3hVY5dlo0AHcwyBWgv7p91Peqk8KuEW3KQKBgQDwOy1sqR2h6QQsyLVI +We2gYx2NOsUaxqw0ek6PlF3BW29aj6H5q4DAkkGAidH5F7Xe2tinJey7nkgLCaE1 +Oav26UriYzQuSgEF5g7KXTxpo5QwSbPDcG+7QNHBG2g96NVSpVEN80tRSyK6VPYD +bfCR2gL9Ly5me33FrOQkiU4CzQKBgQDdJdwF+X0tgN7f9Pz2Yv+M8iEVnLax++KC +XRQdouwgBbL+PdVhCgqi2Cl10krQ0WdcF4sggmwtrHxBPbcyQDrsU2sNAM/Gq9kU +ceDCFq2uthCpJ+zogaJKXz2rxTRbEGE1JKLT+5xh7ZOz/k3UT0SE92pPS81y6MLv +3ozq4nKHcwKBgHReFhjmqsX9W9pdtwK/HQ5uNKhu6X+Y8V3SSS/fzLKXGg+iN/H7 +E7k0n6omGKIyzBSRqhT9l/kiKP+/wGlJ8HUAeRfEukgZ7PjwggWguFzrsiLZ8Mwh +MN5h/bkvD4W9vWf1UJgTXE6auM3NzgXHQZtFIeGG81ENTNVudG0GXdWZAoGAbxlB ++85m0KFZVnGhU7ZQY+KQNGdScP/1v0A7htf+f+fdEFTICcQdq8mkqohHBbjtkBpT +zrU224s3sR7sFdamw2r08Mdjmo9isx6yp071WjwlCpYAMp5NhcdrGAwuTUFhVG1f +T9errJbKCTbMqshXx+T0B3oxcHT22cKYULgKiXkCgYEAwj7XjQ5G13TyqUfO8Lua +I8HTb5ApnPUO5k0Tuk5U6rhuq3GgFs/i94lVsjq2oRgyWr7r8srOZd2xBITYaLxw +vBF+5GUteWuHwC89cP/+21DIrb9g30ueSjyZqVpNsMhZfAyi0tYVQuqSPUN41sCy +HuXOTPwj8eDqJgg2AHGUjHs= -----END PRIVATE KEY----- From 486053d456fe93b0b80ed67662f2840d32c6b40a Mon Sep 17 00:00:00 2001 From: hfuss Date: Sun, 14 Jun 2026 10:19:54 -0400 Subject: [PATCH 03/10] [ffresty] Custom DNS servers and resolver support Signed-off-by: hfuss --- pkg/ffresty/config.go | 6 ++ pkg/ffresty/config_test.go | 2 + pkg/ffresty/ffresty.go | 60 +++++++++++++++++-- pkg/ffresty/ffresty_test.go | 81 ++++++++++++++++++++++++++ pkg/i18n/en_base_field_descriptions.go | 1 + 5 files changed, 145 insertions(+), 5 deletions(-) diff --git a/pkg/ffresty/config.go b/pkg/ffresty/config.go index 0550dd7..102d77a 100644 --- a/pkg/ffresty/config.go +++ b/pkg/ffresty/config.go @@ -81,6 +81,10 @@ const ( // HTTPMaxIdleConnsPerHost the max number of idle connections per host HTTPMaxIdleConnsPerHost = "maxIdleConnsPerHost" + // HTTPDNSServers an optional list of DNS server addresses (host or host:port, port defaults to 53) to use for + // name resolution. Setting this forces use of Go's built-in DNS resolver rather than the system resolver. + HTTPDNSServers = "dnsServers" + // HTTPConnectionTimeout the connection timeout for new connections HTTPConnectionTimeout = "connectionTimeout" // HTTPTLSHandshakeTimeout the TLS handshake connection timeout @@ -112,6 +116,7 @@ func InitConfig(conf config.Section) { conf.AddKnownKey(HTTPMaxIdleConns, defaultHTTPMaxIdleConns) conf.AddKnownKey(HTTPMaxConnsPerHost, defaultHTTPMaxConnsPerHost) conf.AddKnownKey(HTTPMaxIdleConnsPerHost, defaultHTTPMaxIdleConnsPerHost) + conf.AddKnownKey(HTTPDNSServers) conf.AddKnownKey(HTTPConnectionTimeout, defaultHTTPConnectionTimeout) conf.AddKnownKey(HTTPTLSHandshakeTimeout, defaultHTTPTLSHandshakeTimeout) conf.AddKnownKey(HTTPExpectContinueTimeout, defaultHTTPExpectContinueTimeout) @@ -142,6 +147,7 @@ func GenerateConfig(ctx context.Context, conf config.Section) (*Config, error) { HTTPMaxIdleConns: conf.GetInt(HTTPMaxIdleConns), HTTPMaxConnsPerHost: conf.GetInt(HTTPMaxConnsPerHost), HTTPMaxIdleConnsPerHost: conf.GetInt(HTTPMaxIdleConnsPerHost), + DNSServers: conf.GetStringSlice(HTTPDNSServers), HTTPConnectionTimeout: fftypes.FFDuration(conf.GetDuration(HTTPConnectionTimeout)), HTTPTLSHandshakeTimeout: fftypes.FFDuration(conf.GetDuration(HTTPTLSHandshakeTimeout)), HTTPExpectContinueTimeout: fftypes.FFDuration(conf.GetDuration(HTTPExpectContinueTimeout)), diff --git a/pkg/ffresty/config_test.go b/pkg/ffresty/config_test.go index 7cb2117..fb06479 100644 --- a/pkg/ffresty/config_test.go +++ b/pkg/ffresty/config_test.go @@ -47,6 +47,7 @@ func TestWSConfigGeneration(t *testing.T) { utConf.Set(HTTPTLSHandshakeTimeout, 1) utConf.Set(HTTPExpectContinueTimeout, 1) utConf.Set(HTTPPassthroughHeadersEnabled, true) + utConf.Set(HTTPDNSServers, []string{"8.8.8.8", "1.1.1.1:53"}) ctx := context.Background() config, err := GenerateConfig(ctx, utConf) @@ -68,6 +69,7 @@ func TestWSConfigGeneration(t *testing.T) { assert.Equal(t, fftypes.FFDuration(1000000), config.HTTPConnectionTimeout) assert.Equal(t, 1, config.HTTPMaxIdleConns) assert.Equal(t, "custom value", config.HTTPHeaders.GetString("custom-header")) + assert.Equal(t, []string{"8.8.8.8", "1.1.1.1:53"}, config.DNSServers) } func TestWSConfigTLSGenerationFail(t *testing.T) { diff --git a/pkg/ffresty/ffresty.go b/pkg/ffresty/ffresty.go index 2c7fad4..2d7d831 100644 --- a/pkg/ffresty/ffresty.go +++ b/pkg/ffresty/ffresty.go @@ -91,8 +91,10 @@ type HTTPConfig struct { HTTPPassthroughHeadersEnabled bool `ffstruct:"RESTConfig" json:"httpPassthroughHeadersEnabled,omitempty"` HTTPHeaders fftypes.JSONObject `ffstruct:"RESTConfig" json:"headers,omitempty"` HTTPTLSHandshakeTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"tlsHandshakeTimeout,omitempty"` + DNSServers []string `ffstruct:"RESTConfig" json:"dnsServers,omitempty"` // optional DNS servers (host or host:port); forces use of Go's built-in resolver HTTPCustomClient interface{} `json:"-"` TLSClientConfig *tls.Config `json:"-"` // should be built from separate TLSConfig using fftls utils + Resolver *net.Resolver `json:"-"` // programmatic DNS resolver override; takes precedence over DNSServers OnCheckRetry func(res *resty.Response, err error) bool `json:"-"` // response could be nil on err OnBeforeRequest func(req *resty.Request) error `json:"-"` // called before each request, even retry } @@ -209,12 +211,19 @@ func NewWithConfig(ctx context.Context, ffrestyConfig Config) (client *resty.Cli if client == nil { + dialer := &net.Dialer{ + Timeout: time.Duration(ffrestyConfig.HTTPConnectionTimeout), + KeepAlive: time.Duration(ffrestyConfig.HTTPConnectionTimeout), + } + // An explicit programmatic resolver wins; otherwise build one from any configured DNS servers. + // Either way the system resolver is replaced with Go's built-in resolver. + if resolver := dnsResolver(&ffrestyConfig); resolver != nil { + dialer.Resolver = resolver + } + httpTransport := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: time.Duration(ffrestyConfig.HTTPConnectionTimeout), - KeepAlive: time.Duration(ffrestyConfig.HTTPConnectionTimeout), - }).DialContext, + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, ForceAttemptHTTP2: true, MaxIdleConns: ffrestyConfig.HTTPMaxIdleConns, MaxConnsPerHost: ffrestyConfig.HTTPMaxConnsPerHost, @@ -376,6 +385,47 @@ func NewWithConfig(ctx context.Context, ffrestyConfig Config) (client *resty.Cli return client } +// dnsResolver derives the resolver to attach to the dialer based on config precedence: +// - an explicitly provided programmatic Resolver always wins +// - otherwise, if DNSServers are configured, a pure-Go resolver that dials those servers +// (in order, failing over to the next on error) is built +// - otherwise nil, leaving Go's default system resolver selection in place +func dnsResolver(ffrestyConfig *Config) *net.Resolver { + if ffrestyConfig.Resolver != nil { + return ffrestyConfig.Resolver + } + if len(ffrestyConfig.DNSServers) == 0 { + return nil + } + servers := make([]string, len(ffrestyConfig.DNSServers)) + for i, server := range ffrestyConfig.DNSServers { + servers[i] = withDefaultDNSPort(server) + } + timeout := time.Duration(ffrestyConfig.HTTPConnectionTimeout) + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + d := net.Dialer{Timeout: timeout} + var err error + for _, server := range servers { + var conn net.Conn + if conn, err = d.DialContext(ctx, network, server); err == nil { + return conn, nil + } + } + return nil, err + }, + } +} + +// withDefaultDNSPort ensures a DNS server address has a port, defaulting to 53. +func withDefaultDNSPort(server string) string { + if _, _, err := net.SplitHostPort(server); err == nil { + return server + } + return net.JoinHostPort(server, "53") +} + func traceBody(v any) string { switch vt := v.(type) { case string: diff --git a/pkg/ffresty/ffresty_test.go b/pkg/ffresty/ffresty_test.go index 199bc7c..3325e88 100644 --- a/pkg/ffresty/ffresty_test.go +++ b/pkg/ffresty/ffresty_test.go @@ -43,6 +43,7 @@ import ( "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/ffapi" "github.com/hyperledger/firefly-common/pkg/fftls" + "github.com/hyperledger/firefly-common/pkg/fftypes" "github.com/hyperledger/firefly-common/pkg/i18n" "github.com/hyperledger/firefly-common/pkg/metric" "github.com/sirupsen/logrus" @@ -819,3 +820,83 @@ func TestTrace(t *testing.T) { require.JSONEq(t, `{"some":"data"}`, traceBody(map[string]string{"some": "data"})) require.Equal(t, `(binary reader)`, traceBody(strings.NewReader("data to stream"))) } + +func TestWithDefaultDNSPort(t *testing.T) { + assert.Equal(t, "8.8.8.8:53", withDefaultDNSPort("8.8.8.8")) + assert.Equal(t, "8.8.8.8:5353", withDefaultDNSPort("8.8.8.8:5353")) + assert.Equal(t, "[2001:db8::1]:53", withDefaultDNSPort("2001:db8::1")) + assert.Equal(t, "[2001:db8::1]:5353", withDefaultDNSPort("[2001:db8::1]:5353")) +} + +func TestDNSResolverPrecedence(t *testing.T) { + // Nothing configured -> leave Go's default system resolver selection in place + assert.Nil(t, dnsResolver(&Config{})) + + // Programmatic resolver always wins + custom := &net.Resolver{} + assert.Same(t, custom, dnsResolver(&Config{HTTPConfig: HTTPConfig{ + Resolver: custom, + DNSServers: []string{"8.8.8.8"}, + }})) + + // DNSServers builds a pure-Go resolver + r := dnsResolver(&Config{HTTPConfig: HTTPConfig{DNSServers: []string{"8.8.8.8"}}}) + require.NotNil(t, r) + assert.True(t, r.PreferGo) + assert.NotNil(t, r.Dial) +} + +func TestDNSResolverDialFailover(t *testing.T) { + // Stand up a listener acting as the "good" DNS server + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + accepted := make(chan struct{}, 1) + go func() { + conn, acceptErr := ln.Accept() + if acceptErr == nil { + accepted <- struct{}{} + _ = conn.Close() + } + }() + + // First server is unroutable so the dialer must fail over to the live listener + r := dnsResolver(&Config{HTTPConfig: HTTPConfig{ + HTTPConnectionTimeout: fftypes.FFDuration(5 * time.Second), + DNSServers: []string{"127.0.0.1:1", ln.Addr().String()}, + }}) + require.NotNil(t, r) + + conn, err := r.Dial(context.Background(), "tcp", "ignored:53") + require.NoError(t, err) + defer conn.Close() + assert.Equal(t, ln.Addr().String(), conn.RemoteAddr().String()) + + select { + case <-accepted: + case <-time.After(5 * time.Second): + t.Fatal("DNS dial did not reach the configured server") + } +} + +func TestDNSResolverDialAllFail(t *testing.T) { + r := dnsResolver(&Config{HTTPConfig: HTTPConfig{ + HTTPConnectionTimeout: fftypes.FFDuration(250 * time.Millisecond), + DNSServers: []string{"127.0.0.1:1"}, + }}) + require.NotNil(t, r) + _, err := r.Dial(context.Background(), "tcp", "ignored:53") + assert.Error(t, err) +} + +func TestNewWithConfigDNSServersWired(t *testing.T) { + ctx := context.Background() + c := NewWithConfig(ctx, Config{HTTPConfig: HTTPConfig{ + DNSServers: []string{"8.8.8.8"}, + }}) + require.NotNil(t, c) + transport, ok := c.GetClient().Transport.(*http.Transport) + require.True(t, ok) + assert.NotNil(t, transport.DialContext) +} diff --git a/pkg/i18n/en_base_field_descriptions.go b/pkg/i18n/en_base_field_descriptions.go index 639d9dc..76df5cf 100644 --- a/pkg/i18n/en_base_field_descriptions.go +++ b/pkg/i18n/en_base_field_descriptions.go @@ -87,6 +87,7 @@ var ( RESTConfigAuthPassword = ffm("RESTConfig.authPassword", "Password for the HTTP/HTTPS Basic Auth header") RESTConfigAuthUsername = ffm("RESTConfig.authUsername", "Username for the HTTP/HTTPS Basic Auth header") RESTConfigConnectionTimeout = ffm("RESTConfig.connectionTimeout", "HTTP connection timeout") + RESTConfigDNSServers = ffm("RESTConfig.dnsServers", "An optional list of DNS server addresses (host or host:port, port defaults to 53) to use for name resolution. Setting this forces use of Go's built-in DNS resolver rather than the system resolver") RESTConfigExpectContinueTimeout = ffm("RESTConfig.expectContinueTimeout", "Time to wait for the first response from the server after connecting") RESTConfigExpectHeaders = ffm("RESTConfig.headers", "Headers to add to the HTTP call") RESTConfigHTTPPassthroughHeadersEnabled = ffm("RESTConfig.httpPassthroughHeadersEnabled", "Proxy request ID or other configured headers from an upstream microservice connection") From 14f2a49231edc8674b39b953dccf6986f0ed4a4f Mon Sep 17 00:00:00 2001 From: hfuss Date: Sun, 14 Jun 2026 17:05:08 -0400 Subject: [PATCH 04/10] minor fixes Signed-off-by: hfuss --- pkg/ffresty/ffresty.go | 18 +++++++++++++----- pkg/i18n/en_base_config_descriptions.go | 1 + 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pkg/ffresty/ffresty.go b/pkg/ffresty/ffresty.go index 2d7d831..2bd29cb 100644 --- a/pkg/ffresty/ffresty.go +++ b/pkg/ffresty/ffresty.go @@ -394,18 +394,26 @@ func dnsResolver(ffrestyConfig *Config) *net.Resolver { if ffrestyConfig.Resolver != nil { return ffrestyConfig.Resolver } - if len(ffrestyConfig.DNSServers) == 0 { + return NewDNSResolver(ffrestyConfig.DNSServers, time.Duration(ffrestyConfig.HTTPConnectionTimeout)) +} + +// NewDNSResolver builds a pure-Go *net.Resolver that dials the given DNS servers +// (each host or host:port, port defaulting to 53) in order, failing over to the +// next on error. Returns nil when no servers are given (use the system resolver). +// Exported so non-ffresty dialers — e.g. a WebSocket dialer — can honour the same +// dnsServers config as the HTTP client. +func NewDNSResolver(dnsServers []string, dialTimeout time.Duration) *net.Resolver { + if len(dnsServers) == 0 { return nil } - servers := make([]string, len(ffrestyConfig.DNSServers)) - for i, server := range ffrestyConfig.DNSServers { + servers := make([]string, len(dnsServers)) + for i, server := range dnsServers { servers[i] = withDefaultDNSPort(server) } - timeout := time.Duration(ffrestyConfig.HTTPConnectionTimeout) return &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { - d := net.Dialer{Timeout: timeout} + d := net.Dialer{Timeout: dialTimeout} var err error for _, server := range servers { var conn net.Conn diff --git a/pkg/i18n/en_base_config_descriptions.go b/pkg/i18n/en_base_config_descriptions.go index 09df6ab..1ef4b70 100644 --- a/pkg/i18n/en_base_config_descriptions.go +++ b/pkg/i18n/en_base_config_descriptions.go @@ -90,6 +90,7 @@ var ( ConfigGlobalMaxIdleConns = ffc("config.global.maxIdleConns", "The max number of idle connections to hold pooled", IntType) ConfigGlobalMaxConnsPerHost = ffc("config.global.maxConnsPerHost", "The max number of connections, per unique hostname. Zero means no limit", IntType) ConfigGlobalMaxIdleConnsPerHost = ffc("config.global.maxIdleConnsPerHost", "The max number of idle connections, per unique hostname. Zero means net/http uses the default of only 2.", IntType) + ConfigGlobalDNSServers = ffc("config.global.dnsServers", "An optional list of DNS server addresses (host or host:port, port defaults to 53) to use instead of the system resolver. Setting this forces use of Go's built-in DNS resolver.", ArrayStringType) ConfigGlobalMethod = ffc("config.global.method", "The HTTP method to use when making requests to the Address Resolver", StringType) ConfigGlobalAuthType = ffc("config.global.auth.type", "The auth plugin to use for server side authentication of requests", StringType) ConfigGlobalPassthroughHeadersEnabled = ffc("config.global.passthroughHeadersEnabled", "Enable passing through the set of allowed HTTP request headers", BooleanType) From a7b7682c9c6f22832220c7b33a349fdc983188e0 Mon Sep 17 00:00:00 2001 From: hfuss Date: Fri, 19 Jun 2026 13:59:26 -0400 Subject: [PATCH 05/10] ffdns and ffnet packages for instrumented resolver and dialer Signed-off-by: hfuss --- pkg/eventstreams/webhooks_test.go | 4 + pkg/ffdns/config.go | 48 +++++++ pkg/ffdns/ffdns.go | 114 ++++++++++++++++ pkg/ffdns/ffdns_test.go | 200 ++++++++++++++++++++++++++++ pkg/ffnet/config.go | 88 +++++++++++++ pkg/ffnet/ffnet.go | 93 +++++++++++++ pkg/ffnet/ffnet_test.go | 204 +++++++++++++++++++++++++++++ pkg/ffresty/config.go | 23 +++- pkg/ffresty/config_test.go | 6 +- pkg/ffresty/ffresty.go | 114 +++++----------- pkg/ffresty/ffresty_test.go | 105 +++++---------- pkg/i18n/en_base_error_messages.go | 2 + pkg/wsclient/wsclient.go | 30 +++-- pkg/wsclient/wsconfig.go | 16 ++- pkg/wsclient/wsconfig_test.go | 40 ++++++ 15 files changed, 917 insertions(+), 170 deletions(-) create mode 100644 pkg/ffdns/config.go create mode 100644 pkg/ffdns/ffdns.go create mode 100644 pkg/ffdns/ffdns_test.go create mode 100644 pkg/ffnet/config.go create mode 100644 pkg/ffnet/ffnet.go create mode 100644 pkg/ffnet/ffnet_test.go diff --git a/pkg/eventstreams/webhooks_test.go b/pkg/eventstreams/webhooks_test.go index f070c71..02c6dda 100644 --- a/pkg/eventstreams/webhooks_test.go +++ b/pkg/eventstreams/webhooks_test.go @@ -26,6 +26,7 @@ import ( "testing" "github.com/hyperledger/firefly-common/pkg/ffapi" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/ffresty" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/hyperledger/firefly-common/pkg/wsserver" @@ -189,6 +190,9 @@ func TestWebhooksTLS(t *testing.T) { URL: &u, TLSConfigName: &tlsConfName, }, func() { + // The test server listens on loopback, which the default SSRF egress denylist blocks; + // disable it for this webhook client so the delivery can reach the local server. + WebhookDefaultsConfig.SubSection("net").Set(ffnet.CIDRDenylist, []string{}) tls0 := TLSConfigs.ArrayEntry(0) tls0.Set(ConfigTLSConfigName, tlsConfName) tlsConf := tls0.SubSection("tls") diff --git a/pkg/ffdns/config.go b/pkg/ffdns/config.go new file mode 100644 index 0000000..2db50f1 --- /dev/null +++ b/pkg/ffdns/config.go @@ -0,0 +1,48 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed 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 ffdns + +import ( + "time" + + "github.com/hyperledger/firefly-common/pkg/config" +) + +const ( + // DNSServers an optional list of DNS server addresses (host or host:port, port defaults + // to 53). Setting this forces use of Go's built-in resolver rather than the system one. + DNSServers = "dnsServers" + // DNSTimeout the dial timeout when contacting a configured DNS server + DNSTimeout = "dnsTimeout" +) + +type Config struct { + Servers []string + Timeout time.Duration +} + +func InitConfig(conf config.Section) { + conf.AddKnownKey(DNSServers) + conf.AddKnownKey(DNSTimeout) +} + +func GenerateConfig(conf config.Section) (*Config, error) { + return &Config{ + Servers: conf.GetStringSlice(DNSServers), + Timeout: conf.GetDuration(DNSTimeout), + }, nil +} diff --git a/pkg/ffdns/ffdns.go b/pkg/ffdns/ffdns.go new file mode 100644 index 0000000..dfe5bc4 --- /dev/null +++ b/pkg/ffdns/ffdns.go @@ -0,0 +1,114 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed 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 ffdns + +import ( + "context" + "errors" + "net" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/metric" +) + +const ( + metricsDNSRequestsTotal = "dns_requests_total" + metricsDNSResponsesTotal = "dns_responses_total" + metricsDNSErrorsTotal = "dns_errors_total" +) + +var metricsManager metric.MetricsManager + +func EnableResolverMetrics(ctx context.Context, metricsRegistry metric.MetricsRegistry) { + if metricsManager != nil { + return + } + metricsManager, _ = metricsRegistry.NewMetricsManagerForSubsystem(ctx, "dns") + metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSRequestsTotal, "DNS requests", []string{"server"}, false) + metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSResponsesTotal, "DNS responses", []string{"server", "status"}, false) + metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSErrorsTotal, "DNS errors", []string{"server", "error"}, false) +} + +// NewDNSResolver builds a pure-Go *net.Resolver that dials the given DNS servers +// (each host or host:port, port defaulting to 53) in order, failing over to the +// next on error. Returns nil when no servers are given (use the system resolver). +// Exported so non-ffresty dialers — e.g. a WebSocket dialer — can honour the same +// dnsServers config as the HTTP client. +func NewResolver(config config.Section) *net.Resolver { + cfg, err := GenerateConfig(config) + if err != nil { + return nil + } + + return NewResolverWithConfig(cfg) +} + +func NewResolverWithConfig(cfg *Config) *net.Resolver { + if len(cfg.Servers) == 0 { + return nil // TODO no matter what do we want / need DNS metrics ? + } + servers := make([]string, len(cfg.Servers)) + for i, server := range cfg.Servers { + servers[i] = withDefaultDNSPort(server) + } + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + d := net.Dialer{Timeout: cfg.Timeout} + var err error + // Go's built-in resolver dials a fresh connection per query exchange (escalating + // from UDP to TCP for truncated responses), so each Dial maps to a DNS request. We + // record metrics at this connection level; richer rcode-level metrics would require + // parsing the DNS response off the returned conn. + for _, server := range servers { + recordDNSMetric(ctx, metricsDNSRequestsTotal, map[string]string{"server": server}) + var conn net.Conn + if conn, err = d.DialContext(ctx, network, server); err == nil { + recordDNSMetric(ctx, metricsDNSResponsesTotal, map[string]string{"server": server, "status": "success"}) + return conn, nil + } + recordDNSMetric(ctx, metricsDNSErrorsTotal, map[string]string{"server": server, "error": classifyDNSError(err)}) + } + return nil, err + }, + } +} + +// recordDNSMetric increments a DNS counter when resolver metrics have been enabled, and is a no-op otherwise. +func recordDNSMetric(ctx context.Context, name string, labels map[string]string) { + if metricsManager == nil { + return + } + metricsManager.IncCounterMetricWithLabels(ctx, name, labels, nil) +} + +// classifyDNSError maps a dial error to a low-cardinality label so the dns_errors_total metric doesn't explode. +func classifyDNSError(err error) string { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "timeout" + } + return "error" +} + +// withDefaultDNSPort ensures a DNS server address has a port, defaulting to 53. +func withDefaultDNSPort(server string) string { + if _, _, err := net.SplitHostPort(server); err == nil { + return server + } + return net.JoinHostPort(server, "53") +} diff --git a/pkg/ffdns/ffdns_test.go b/pkg/ffdns/ffdns_test.go new file mode 100644 index 0000000..f35e9df --- /dev/null +++ b/pkg/ffdns/ffdns_test.go @@ -0,0 +1,200 @@ +// Copyright © 2025 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed 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 ffdns + +import ( + "context" + "net" + "strings" + "testing" + "time" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/metric" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// counterTotal sums the values of all series of a counter whose metric family name ends with +// the given suffix (the registry prefixes names with component + subsystem). +func counterTotal(t *testing.T, mr metric.MetricsRegistry, nameSuffix string) float64 { + families, err := mr.GetGatherer().Gather() + require.NoError(t, err) + var total float64 + for _, mf := range families { + if strings.HasSuffix(mf.GetName(), nameSuffix) { + for _, m := range mf.GetMetric() { + if c := m.GetCounter(); c != nil { + total += c.GetValue() + } + } + } + } + return total +} + +var utConf = config.RootSection("dns_unit_tests") + +func resetConf() { + config.RootConfigReset() + InitConfig(utConf) +} + +func TestWithDefaultDNSPort(t *testing.T) { + assert.Equal(t, "8.8.8.8:53", withDefaultDNSPort("8.8.8.8")) + assert.Equal(t, "8.8.8.8:5353", withDefaultDNSPort("8.8.8.8:5353")) + assert.Equal(t, "[2001:db8::1]:53", withDefaultDNSPort("2001:db8::1")) + assert.Equal(t, "[2001:db8::1]:5353", withDefaultDNSPort("[2001:db8::1]:5353")) +} + +func TestNewResolverWithConfig(t *testing.T) { + // No servers -> nil, leaving Go's default system resolver selection in place + assert.Nil(t, NewResolverWithConfig(&Config{})) + + // Servers configured -> pure-Go resolver + r := NewResolverWithConfig(&Config{Servers: []string{"8.8.8.8"}}) + require.NotNil(t, r) + assert.True(t, r.PreferGo) + assert.NotNil(t, r.Dial) +} + +func TestNewResolverFromConfigSection(t *testing.T) { + resetConf() + utConf.Set(DNSServers, []string{"8.8.8.8", "1.1.1.1:53"}) + r := NewResolver(utConf) + require.NotNil(t, r) + assert.True(t, r.PreferGo) + + resetConf() + assert.Nil(t, NewResolver(utConf)) +} + +func TestResolverDialFailover(t *testing.T) { + // Stand up a listener acting as the "good" DNS server + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + accepted := make(chan struct{}, 1) + go func() { + conn, acceptErr := ln.Accept() + if acceptErr == nil { + accepted <- struct{}{} + _ = conn.Close() + } + }() + + // First server is unroutable so the dialer must fail over to the live listener + r := NewResolverWithConfig(&Config{ + Timeout: 5 * time.Second, + Servers: []string{"127.0.0.1:1", ln.Addr().String()}, + }) + require.NotNil(t, r) + + conn, err := r.Dial(context.Background(), "tcp", "ignored:53") + require.NoError(t, err) + defer conn.Close() + assert.Equal(t, ln.Addr().String(), conn.RemoteAddr().String()) + + select { + case <-accepted: + case <-time.After(5 * time.Second): + t.Fatal("DNS dial did not reach the configured server") + } +} + +func TestResolverDialAllFail(t *testing.T) { + r := NewResolverWithConfig(&Config{ + Timeout: 250 * time.Millisecond, + Servers: []string{"127.0.0.1:1"}, + }) + require.NotNil(t, r) + _, err := r.Dial(context.Background(), "tcp", "ignored:53") + assert.Error(t, err) +} + +func TestEnableResolverMetrics(t *testing.T) { + metricsManager = nil + defer func() { metricsManager = nil }() + + ctx := context.Background() + mr := metric.NewPrometheusMetricsRegistry("test") + EnableResolverMetrics(ctx, mr) + require.NotNil(t, metricsManager) + + // Idempotent - a second call is a no-op rather than re-registering + EnableResolverMetrics(ctx, mr) +} + +func TestResolverDialRecordsMetrics(t *testing.T) { + metricsManager = nil + defer func() { metricsManager = nil }() + + ctx := context.Background() + mr := metric.NewPrometheusMetricsRegistry("test") + EnableResolverMetrics(ctx, mr) + + // Live listener acts as the second (good) DNS server; the first is unroutable so a single + // Dial exercises the request, error (failover), and response metric paths together. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + go func() { + if conn, acceptErr := ln.Accept(); acceptErr == nil { + _ = conn.Close() + } + }() + + r := NewResolverWithConfig(&Config{ + Timeout: 5 * time.Second, + Servers: []string{"127.0.0.1:1", ln.Addr().String()}, + }) + require.NotNil(t, r) + conn, err := r.Dial(ctx, "tcp", "ignored:53") + require.NoError(t, err) + defer conn.Close() + + assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_requests_total"), float64(2), "one request per server attempted") + assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_responses_total"), float64(1), "one successful response") + assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_errors_total"), float64(1), "first server failed over") +} + +func TestResolverDialNoMetricsWhenDisabled(t *testing.T) { + metricsManager = nil // metrics not enabled -> recording is a no-op, no panic + r := NewResolverWithConfig(&Config{ + Timeout: 250 * time.Millisecond, + Servers: []string{"127.0.0.1:1"}, + }) + require.NotNil(t, r) + _, err := r.Dial(context.Background(), "tcp", "ignored:53") + assert.Error(t, err) +} + +func TestClassifyDNSError(t *testing.T) { + assert.Equal(t, "error", classifyDNSError(assertAnErr{})) + assert.Equal(t, "timeout", classifyDNSError(timeoutErr{})) +} + +type assertAnErr struct{} + +func (assertAnErr) Error() string { return "boom" } + +type timeoutErr struct{} + +func (timeoutErr) Error() string { return "i/o timeout" } +func (timeoutErr) Timeout() bool { return true } +func (timeoutErr) Temporary() bool { return true } diff --git a/pkg/ffnet/config.go b/pkg/ffnet/config.go new file mode 100644 index 0000000..e879c9d --- /dev/null +++ b/pkg/ffnet/config.go @@ -0,0 +1,88 @@ +// Copyright © 2025 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed 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 ffnet builds outbound net.Dialers and their egress controls: a custom DNS resolver +// (via ffdns) plus a CIDR egress denylist for SSRF protection. It is the single place to +// configure how — and where — a client is allowed to make outbound connections. +package ffnet + +import ( + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" +) + +const ( + // CIDRDenylist fully overrides the built-in default denylist of reserved/metadata ranges + // blocked to mitigate SSRF. Leave unset to keep the secure defaults. Set to an empty list + // to disable denylisting entirely (e.g. to allow a localhost target). + CIDRDenylist = "cidrDenylist" + // AdditionalDeniedCIDRs appends extra CIDRs on top of the effective base denylist (either + // the built-in defaults or a configured cidrDenylist override). + AdditionalDeniedCIDRs = "additionalDeniedCIDRs" +) + +// DefaultDeniedCIDRs are blocked by default to mitigate SSRF: loopback, link-local (including +// the cloud metadata endpoint 169.254.169.254 and the AWS IMDS IPv6 endpoint), unspecified / +// "this host", and multicast / reserved / broadcast ranges. Private RFC1918 / IPv6 ULA ranges +// are intentionally NOT included — these dialers are commonly used for legitimate internal +// service-to-service calls, so blocking private space is deferred to network firewalls / +// zero-trust rather than baked in (callers wanting that can use additionalDeniedCIDRs). +var DefaultDeniedCIDRs = []string{ + "0.0.0.0/8", // unspecified / "this host" (RFC 1122) + "127.0.0.0/8", // IPv4 loopback + "169.254.0.0/16", // IPv4 link-local, incl. cloud metadata 169.254.169.254 + "224.0.0.0/4", // IPv4 multicast + "240.0.0.0/4", // IPv4 reserved (incl. 255.255.255.255 broadcast) + "::1/128", // IPv6 loopback + "::/128", // IPv6 unspecified + "fe80::/10", // IPv6 link-local + "fd00:ec2::254/128", // AWS IMDS IPv6 endpoint (cloud metadata) + "ff00::/8", // IPv6 multicast +} + +// Config is the combined outbound-dialer configuration: the DNS resolver settings plus the +// egress CIDR denylist. +type Config struct { + DNS ffdns.Config + // CIDRDenylist, when non-nil, fully replaces DefaultDeniedCIDRs (an empty non-nil slice + // disables denylisting entirely). Leave nil to keep the secure defaults. + CIDRDenylist []string + // AdditionalDeniedCIDRs is appended on top of the effective base denylist. + AdditionalDeniedCIDRs []string +} + +func InitConfig(conf config.Section) { + ffdns.InitConfig(conf) + conf.AddKnownKey(CIDRDenylist) + conf.AddKnownKey(AdditionalDeniedCIDRs) +} + +func GenerateConfig(conf config.Section) (*Config, error) { + dnsCfg, err := ffdns.GenerateConfig(conf) + if err != nil { + return nil, err + } + cfg := &Config{ + DNS: *dnsCfg, + AdditionalDeniedCIDRs: conf.GetStringSlice(AdditionalDeniedCIDRs), + } + // Distinguish "unset" (keep secure defaults) from an explicit override (including an + // empty list, which disables denylisting). + if conf.IsSet(CIDRDenylist) { + cfg.CIDRDenylist = conf.GetStringSlice(CIDRDenylist) + } + return cfg, nil +} diff --git a/pkg/ffnet/ffnet.go b/pkg/ffnet/ffnet.go new file mode 100644 index 0000000..02b62e6 --- /dev/null +++ b/pkg/ffnet/ffnet.go @@ -0,0 +1,93 @@ +// Copyright © 2025 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed 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 ffnet + +import ( + "context" + "net" + "syscall" + + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +// Resolver returns the custom DNS resolver for this config, or nil to use the system resolver. +func (cfg *Config) Resolver() *net.Resolver { + return ffdns.NewResolverWithConfig(&cfg.DNS) +} + +// NewDialer builds a *net.Dialer wired with the custom DNS resolver (if any) and the SSRF +// egress guard (on by default). The caller is responsible for setting Timeout / KeepAlive to +// suit its protocol. Exported so any dialer-based client — HTTP, WebSocket, etc. — can apply +// identical outbound protection from the same config. +func NewDialer(ctx context.Context, cfg *Config) (*net.Dialer, error) { + control, err := NewDialControl(ctx, cfg) + if err != nil { + return nil, err + } + return &net.Dialer{ + Resolver: cfg.Resolver(), + Control: control, + }, nil +} + +// effectiveDenylist resolves the base denylist (the configured override, or the secure +// defaults when unset) and appends any additional CIDRs. +func effectiveDenylist(cfg *Config) []string { + base := cfg.CIDRDenylist + if base == nil { + base = DefaultDeniedCIDRs + } + return append(append([]string{}, base...), cfg.AdditionalDeniedCIDRs...) +} + +// NewDialControl builds a net.Dialer Control function that rejects connections to any address +// inside the effective CIDR denylist — the core SSRF mitigation. It runs after DNS resolution +// against the actual resolved IP, so it also defeats DNS-rebinding and literal-IP bypasses. +// Returns (nil, nil) when the effective denylist is empty (no restrictions). +func NewDialControl(ctx context.Context, cfg *Config) (func(network, address string, c syscall.RawConn) error, error) { + entries := effectiveDenylist(cfg) + if len(entries) == 0 { + return nil, nil + } + denied := make([]*net.IPNet, 0, len(entries)) + for _, entry := range entries { + _, ipNet, err := net.ParseCIDR(entry) + if err != nil { + return nil, i18n.NewError(ctx, i18n.MsgInvalidCIDR, entry) + } + denied = append(denied, ipNet) + } + return func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + ip := net.ParseIP(host) + if ip == nil { + // Control is always invoked with a resolved IP literal; if it isn't one, fail + // closed rather than allow an unexpected target through. + return i18n.NewError(ctx, i18n.MsgConnectionToCIDRBlocked, address, "unparseable address") + } + for _, ipNet := range denied { + if ipNet.Contains(ip) { + return i18n.NewError(ctx, i18n.MsgConnectionToCIDRBlocked, ip.String(), ipNet.String()) + } + } + return nil + }, nil +} diff --git a/pkg/ffnet/ffnet_test.go b/pkg/ffnet/ffnet_test.go new file mode 100644 index 0000000..b5b3ed3 --- /dev/null +++ b/pkg/ffnet/ffnet_test.go @@ -0,0 +1,204 @@ +// Copyright © 2025 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed 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 ffnet + +import ( + "context" + "net" + "testing" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var utConf = config.RootSection("net_unit_tests") + +func resetConf() { + config.RootConfigReset() + InitConfig(utConf) +} + +func TestNewDialControlDefaults(t *testing.T) { + // nil CIDRDenylist => secure defaults + control, err := NewDialControl(context.Background(), &Config{}) + require.NoError(t, err) + require.NotNil(t, control) + + // Blocked by default: loopback, link-local/cloud-metadata, unspecified, multicast/reserved + assert.Error(t, control("tcp", "127.0.0.1:8080", nil)) + assert.Error(t, control("tcp", "169.254.169.254:80", nil)) // cloud metadata + assert.Error(t, control("tcp", "0.0.0.0:80", nil)) + assert.Error(t, control("tcp", "[::1]:443", nil)) + assert.Error(t, control("tcp", "[fd00:ec2::254]:80", nil)) // AWS IMDS IPv6 + assert.Error(t, control("tcp", "224.0.0.1:80", nil)) // IPv4 multicast + assert.Error(t, control("tcp", "255.255.255.255:80", nil)) // IPv4 broadcast (reserved) + assert.Error(t, control("tcp", "240.0.0.1:80", nil)) // IPv4 reserved + assert.Error(t, control("tcp", "[ff02::1]:80", nil)) // IPv6 multicast + + // NOT blocked by default: public, and private RFC1918 (deferred to firewalls) + assert.NoError(t, control("tcp", "8.8.8.8:443", nil)) + assert.NoError(t, control("tcp", "10.1.2.3:80", nil)) + assert.NoError(t, control("tcp", "192.168.1.5:80", nil)) + + // IPv4-mapped IPv6 loopback is normalized and still blocked + assert.Error(t, control("tcp", "[::ffff:127.0.0.1]:80", nil)) +} + +func TestNewDialControlOverride(t *testing.T) { + // Explicit empty override disables denylisting entirely + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: []string{}}) + require.NoError(t, err) + assert.Nil(t, control) + + // Custom override fully replaces defaults + control, err = NewDialControl(context.Background(), &Config{CIDRDenylist: []string{"10.0.0.0/8"}}) + require.NoError(t, err) + require.NotNil(t, control) + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // now blocked + assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // defaults no longer apply +} + +func TestNewDialControlAdditional(t *testing.T) { + // Additional CIDRs extend the defaults + control, err := NewDialControl(context.Background(), &Config{AdditionalDeniedCIDRs: []string{"10.0.0.0/8"}}) + require.NoError(t, err) + require.NotNil(t, control) + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // added + assert.Error(t, control("tcp", "127.0.0.1:80", nil)) // default still applies +} + +func TestNewDialControlInvalidCIDR(t *testing.T) { + _, err := NewDialControl(context.Background(), &Config{AdditionalDeniedCIDRs: []string{"not-a-cidr"}}) + assert.Regexp(t, "FF00260", err) +} + +func TestDialControlBlocksUnparseableAddress(t *testing.T) { + control, err := NewDialControl(context.Background(), &Config{}) + require.NoError(t, err) + // Fail closed if an address somehow isn't a resolved IP literal + assert.Regexp(t, "FF00261", control("tcp", "not-an-ip:80", nil)) +} + +func TestDialControlBareIPNoPort(t *testing.T) { + // Addresses without a port still resolve their IP (SplitHostPort error fallback) + control, err := NewDialControl(context.Background(), &Config{}) + require.NoError(t, err) + assert.Error(t, control("tcp", "127.0.0.1", nil)) // blocked + assert.NoError(t, control("tcp", "8.8.8.8", nil)) // allowed + assert.Error(t, control("tcp", "169.254.169.254", nil)) // metadata blocked +} + +func TestNewDialerEndToEndBlocks(t *testing.T) { + // The guard fires through a real net.Dialer dial, before any connection is made + d, err := NewDialer(context.Background(), &Config{}) + require.NoError(t, err) + require.NotNil(t, d.Control) + _, err = d.DialContext(context.Background(), "tcp", "127.0.0.1:1") + assert.Regexp(t, "FF00261", err) +} + +func TestNewDialerAllowsAndConnects(t *testing.T) { + // With the denylist disabled, the dialer connects normally to a loopback listener + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + go func() { + if conn, acceptErr := ln.Accept(); acceptErr == nil { + _ = conn.Close() + } + }() + + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: []string{}}) + require.NoError(t, err) + assert.Nil(t, d.Control) + conn, err := d.DialContext(context.Background(), "tcp", ln.Addr().String()) + require.NoError(t, err) + require.NotNil(t, conn) + _ = conn.Close() +} + +func TestGenerateConfigOverrideReplacesDefaults(t *testing.T) { + resetConf() + utConf.Set(CIDRDenylist, []string{"10.0.0.0/8"}) + cfg, err := GenerateConfig(utConf) + require.NoError(t, err) + assert.Equal(t, []string{"10.0.0.0/8"}, cfg.CIDRDenylist) + + control, err := NewDialControl(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, control) + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // override entry blocked + assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // defaults replaced, loopback allowed +} + +func TestNewDialer(t *testing.T) { + // Default config => dialer with the SSRF guard wired (resolver nil since no DNS servers) + d, err := NewDialer(context.Background(), &Config{}) + require.NoError(t, err) + require.NotNil(t, d) + assert.Nil(t, d.Resolver) + require.NotNil(t, d.Control) + assert.Error(t, d.Control("tcp", "127.0.0.1:80", nil)) + + // DNS servers => resolver attached; empty denylist => no control + d, err = NewDialer(context.Background(), &Config{ + DNS: ffdns.Config{Servers: []string{"8.8.8.8"}}, + CIDRDenylist: []string{}, + }) + require.NoError(t, err) + require.NotNil(t, d.Resolver) + assert.Nil(t, d.Control) + + // Invalid CIDR propagates as an error + _, err = NewDialer(context.Background(), &Config{AdditionalDeniedCIDRs: []string{"bad"}}) + assert.Regexp(t, "FF00260", err) +} + +func TestGenerateConfigDenylistSemantics(t *testing.T) { + // Unset cidrDenylist => defaults active (control blocks loopback) + resetConf() + cfg, err := GenerateConfig(utConf) + require.NoError(t, err) + assert.Nil(t, cfg.CIDRDenylist) + control, err := NewDialControl(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, control) + assert.Error(t, control("tcp", "127.0.0.1:80", nil)) + + // Explicitly-set empty cidrDenylist => disabled + resetConf() + utConf.Set(CIDRDenylist, []string{}) + cfg, err = GenerateConfig(utConf) + require.NoError(t, err) + assert.NotNil(t, cfg.CIDRDenylist) + control, err = NewDialControl(context.Background(), cfg) + require.NoError(t, err) + assert.Nil(t, control) +} + +func TestGenerateConfigDNSAndAdditional(t *testing.T) { + resetConf() + utConf.Set(ffdns.DNSServers, []string{"8.8.8.8"}) + utConf.Set(AdditionalDeniedCIDRs, []string{"10.0.0.0/8"}) + cfg, err := GenerateConfig(utConf) + require.NoError(t, err) + assert.Equal(t, []string{"8.8.8.8"}, cfg.DNS.Servers) + assert.Equal(t, []string{"10.0.0.0/8"}, cfg.AdditionalDeniedCIDRs) + assert.NotNil(t, cfg.Resolver()) +} diff --git a/pkg/ffresty/config.go b/pkg/ffresty/config.go index 102d77a..87d5b5e 100644 --- a/pkg/ffresty/config.go +++ b/pkg/ffresty/config.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -20,6 +20,7 @@ import ( "context" "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/hyperledger/firefly-common/pkg/fftypes" ) @@ -81,10 +82,6 @@ const ( // HTTPMaxIdleConnsPerHost the max number of idle connections per host HTTPMaxIdleConnsPerHost = "maxIdleConnsPerHost" - // HTTPDNSServers an optional list of DNS server addresses (host or host:port, port defaults to 53) to use for - // name resolution. Setting this forces use of Go's built-in DNS resolver rather than the system resolver. - HTTPDNSServers = "dnsServers" - // HTTPConnectionTimeout the connection timeout for new connections HTTPConnectionTimeout = "connectionTimeout" // HTTPTLSHandshakeTimeout the TLS handshake connection timeout @@ -116,7 +113,6 @@ func InitConfig(conf config.Section) { conf.AddKnownKey(HTTPMaxIdleConns, defaultHTTPMaxIdleConns) conf.AddKnownKey(HTTPMaxConnsPerHost, defaultHTTPMaxConnsPerHost) conf.AddKnownKey(HTTPMaxIdleConnsPerHost, defaultHTTPMaxIdleConnsPerHost) - conf.AddKnownKey(HTTPDNSServers) conf.AddKnownKey(HTTPConnectionTimeout, defaultHTTPConnectionTimeout) conf.AddKnownKey(HTTPTLSHandshakeTimeout, defaultHTTPTLSHandshakeTimeout) conf.AddKnownKey(HTTPExpectContinueTimeout, defaultHTTPExpectContinueTimeout) @@ -125,6 +121,9 @@ func InitConfig(conf config.Section) { tlsConfig := conf.SubSection("tls") fftls.InitTLSConfig(tlsConfig) + + netConfig := conf.SubSection("net") + ffnet.InitConfig(netConfig) } func GenerateConfig(ctx context.Context, conf config.Section) (*Config, error) { @@ -147,7 +146,6 @@ func GenerateConfig(ctx context.Context, conf config.Section) (*Config, error) { HTTPMaxIdleConns: conf.GetInt(HTTPMaxIdleConns), HTTPMaxConnsPerHost: conf.GetInt(HTTPMaxConnsPerHost), HTTPMaxIdleConnsPerHost: conf.GetInt(HTTPMaxIdleConnsPerHost), - DNSServers: conf.GetStringSlice(HTTPDNSServers), HTTPConnectionTimeout: fftypes.FFDuration(conf.GetDuration(HTTPConnectionTimeout)), HTTPTLSHandshakeTimeout: fftypes.FFDuration(conf.GetDuration(HTTPTLSHandshakeTimeout)), HTTPExpectContinueTimeout: fftypes.FFDuration(conf.GetDuration(HTTPExpectContinueTimeout)), @@ -163,5 +161,16 @@ func GenerateConfig(ctx context.Context, conf config.Section) (*Config, error) { ffrestyConfig.TLSClientConfig = tlsClientConfig + netCfg, err := ffnet.GenerateConfig(conf.SubSection("net")) + if err != nil { + return nil, err + } + ffrestyConfig.Resolver = netCfg.Resolver() + dialControl, err := ffnet.NewDialControl(ctx, netCfg) + if err != nil { + return nil, err + } + ffrestyConfig.DialControl = dialControl + return ffrestyConfig, nil } diff --git a/pkg/ffresty/config_test.go b/pkg/ffresty/config_test.go index fb06479..35936c3 100644 --- a/pkg/ffresty/config_test.go +++ b/pkg/ffresty/config_test.go @@ -20,6 +20,7 @@ import ( "context" "testing" + "github.com/hyperledger/firefly-common/pkg/ffdns" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/hyperledger/firefly-common/pkg/fftypes" "github.com/stretchr/testify/assert" @@ -47,7 +48,7 @@ func TestWSConfigGeneration(t *testing.T) { utConf.Set(HTTPTLSHandshakeTimeout, 1) utConf.Set(HTTPExpectContinueTimeout, 1) utConf.Set(HTTPPassthroughHeadersEnabled, true) - utConf.Set(HTTPDNSServers, []string{"8.8.8.8", "1.1.1.1:53"}) + utConf.SubSection("net").Set(ffdns.DNSServers, []string{"8.8.8.8", "1.1.1.1:53"}) ctx := context.Background() config, err := GenerateConfig(ctx, utConf) @@ -69,7 +70,8 @@ func TestWSConfigGeneration(t *testing.T) { assert.Equal(t, fftypes.FFDuration(1000000), config.HTTPConnectionTimeout) assert.Equal(t, 1, config.HTTPMaxIdleConns) assert.Equal(t, "custom value", config.HTTPHeaders.GetString("custom-header")) - assert.Equal(t, []string{"8.8.8.8", "1.1.1.1:53"}, config.DNSServers) + // dns.servers drives a programmatic resolver built via ffdns + assert.NotNil(t, config.Resolver) } func TestWSConfigTLSGenerationFail(t *testing.T) { diff --git a/pkg/ffresty/ffresty.go b/pkg/ffresty/ffresty.go index 2bd29cb..c0b7866 100644 --- a/pkg/ffresty/ffresty.go +++ b/pkg/ffresty/ffresty.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -28,6 +28,7 @@ import ( "net/url" "regexp" "strings" + "syscall" "time" "github.com/go-resty/resty/v2" @@ -70,33 +71,33 @@ var ( // HTTPConfig is all the optional configuration separate to the URL you wish to invoke. // This is JSON serializable with docs, so you can embed it into API objects. type HTTPConfig struct { - ProxyURL string `ffstruct:"RESTConfig" json:"proxyURL,omitempty"` - HTTPRequestTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"requestTimeout,omitempty"` - HTTPIdleConnTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"idleTimeout,omitempty"` - HTTPMaxIdleTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"maxIdleTimeout,omitempty"` - HTTPConnectionTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"connectionTimeout,omitempty"` - HTTPExpectContinueTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"expectContinueTimeout,omitempty"` - AuthUsername string `ffstruct:"RESTConfig" json:"authUsername,omitempty"` - AuthPassword string `ffstruct:"RESTConfig" json:"authPassword,omitempty"` - ThrottleRequestsPerSecond int `ffstruct:"RESTConfig" json:"requestsPerSecond,omitempty"` - ThrottleBurst int `ffstruct:"RESTConfig" json:"burst,omitempty"` - Retry bool `ffstruct:"RESTConfig" json:"retry,omitempty"` - RetryCount int `ffstruct:"RESTConfig" json:"retryCount,omitempty"` - RetryInitialDelay fftypes.FFDuration `ffstruct:"RESTConfig" json:"retryInitialDelay,omitempty"` - RetryMaximumDelay fftypes.FFDuration `ffstruct:"RESTConfig" json:"retryMaximumDelay,omitempty"` - RetryErrorStatusCodeRegex string `ffstruct:"RESTConfig" json:"retryErrorStatusCodeRegex,omitempty"` - HTTPMaxIdleConns int `ffstruct:"RESTConfig" json:"maxIdleConns,omitempty"` - HTTPMaxConnsPerHost int `ffstruct:"RESTConfig" json:"maxConnsPerHost,omitempty"` - HTTPMaxIdleConnsPerHost int `ffstruct:"RESTConfig" json:"maxIdleConnsPerHost,omitempty"` - HTTPPassthroughHeadersEnabled bool `ffstruct:"RESTConfig" json:"httpPassthroughHeadersEnabled,omitempty"` - HTTPHeaders fftypes.JSONObject `ffstruct:"RESTConfig" json:"headers,omitempty"` - HTTPTLSHandshakeTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"tlsHandshakeTimeout,omitempty"` - DNSServers []string `ffstruct:"RESTConfig" json:"dnsServers,omitempty"` // optional DNS servers (host or host:port); forces use of Go's built-in resolver - HTTPCustomClient interface{} `json:"-"` - TLSClientConfig *tls.Config `json:"-"` // should be built from separate TLSConfig using fftls utils - Resolver *net.Resolver `json:"-"` // programmatic DNS resolver override; takes precedence over DNSServers - OnCheckRetry func(res *resty.Response, err error) bool `json:"-"` // response could be nil on err - OnBeforeRequest func(req *resty.Request) error `json:"-"` // called before each request, even retry + ProxyURL string `ffstruct:"RESTConfig" json:"proxyURL,omitempty"` + HTTPRequestTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"requestTimeout,omitempty"` + HTTPIdleConnTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"idleTimeout,omitempty"` + HTTPMaxIdleTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"maxIdleTimeout,omitempty"` + HTTPConnectionTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"connectionTimeout,omitempty"` + HTTPExpectContinueTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"expectContinueTimeout,omitempty"` + AuthUsername string `ffstruct:"RESTConfig" json:"authUsername,omitempty"` + AuthPassword string `ffstruct:"RESTConfig" json:"authPassword,omitempty"` + ThrottleRequestsPerSecond int `ffstruct:"RESTConfig" json:"requestsPerSecond,omitempty"` + ThrottleBurst int `ffstruct:"RESTConfig" json:"burst,omitempty"` + Retry bool `ffstruct:"RESTConfig" json:"retry,omitempty"` + RetryCount int `ffstruct:"RESTConfig" json:"retryCount,omitempty"` + RetryInitialDelay fftypes.FFDuration `ffstruct:"RESTConfig" json:"retryInitialDelay,omitempty"` + RetryMaximumDelay fftypes.FFDuration `ffstruct:"RESTConfig" json:"retryMaximumDelay,omitempty"` + RetryErrorStatusCodeRegex string `ffstruct:"RESTConfig" json:"retryErrorStatusCodeRegex,omitempty"` + HTTPMaxIdleConns int `ffstruct:"RESTConfig" json:"maxIdleConns,omitempty"` + HTTPMaxConnsPerHost int `ffstruct:"RESTConfig" json:"maxConnsPerHost,omitempty"` + HTTPMaxIdleConnsPerHost int `ffstruct:"RESTConfig" json:"maxIdleConnsPerHost,omitempty"` + HTTPPassthroughHeadersEnabled bool `ffstruct:"RESTConfig" json:"httpPassthroughHeadersEnabled,omitempty"` + HTTPHeaders fftypes.JSONObject `ffstruct:"RESTConfig" json:"headers,omitempty"` + HTTPTLSHandshakeTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"tlsHandshakeTimeout,omitempty"` + HTTPCustomClient interface{} `json:"-"` + TLSClientConfig *tls.Config `json:"-"` // should be built from separate TLSConfig using fftls utils + Resolver *net.Resolver `json:"-"` // programmatic DNS resolver override; takes precedence over DNSServers + DialControl func(network, address string, c syscall.RawConn) error `json:"-"` // SSRF CIDR-denylist guard applied to the dialer; built from the dns config via ffdns + OnCheckRetry func(res *resty.Response, err error) bool `json:"-"` // response could be nil on err + OnBeforeRequest func(req *resty.Request) error `json:"-"` // called before each request, even retry } func EnableClientMetrics(ctx context.Context, metricsRegistry metric.MetricsRegistry) error { @@ -217,8 +218,12 @@ func NewWithConfig(ctx context.Context, ffrestyConfig Config) (client *resty.Cli } // An explicit programmatic resolver wins; otherwise build one from any configured DNS servers. // Either way the system resolver is replaced with Go's built-in resolver. - if resolver := dnsResolver(&ffrestyConfig); resolver != nil { - dialer.Resolver = resolver + if ffrestyConfig.Resolver != nil { + dialer.Resolver = ffrestyConfig.Resolver + } + // SSRF CIDR-denylist guard, checked against the resolved IP just before connect. + if ffrestyConfig.DialControl != nil { + dialer.Control = ffrestyConfig.DialControl } httpTransport := &http.Transport{ @@ -385,55 +390,6 @@ func NewWithConfig(ctx context.Context, ffrestyConfig Config) (client *resty.Cli return client } -// dnsResolver derives the resolver to attach to the dialer based on config precedence: -// - an explicitly provided programmatic Resolver always wins -// - otherwise, if DNSServers are configured, a pure-Go resolver that dials those servers -// (in order, failing over to the next on error) is built -// - otherwise nil, leaving Go's default system resolver selection in place -func dnsResolver(ffrestyConfig *Config) *net.Resolver { - if ffrestyConfig.Resolver != nil { - return ffrestyConfig.Resolver - } - return NewDNSResolver(ffrestyConfig.DNSServers, time.Duration(ffrestyConfig.HTTPConnectionTimeout)) -} - -// NewDNSResolver builds a pure-Go *net.Resolver that dials the given DNS servers -// (each host or host:port, port defaulting to 53) in order, failing over to the -// next on error. Returns nil when no servers are given (use the system resolver). -// Exported so non-ffresty dialers — e.g. a WebSocket dialer — can honour the same -// dnsServers config as the HTTP client. -func NewDNSResolver(dnsServers []string, dialTimeout time.Duration) *net.Resolver { - if len(dnsServers) == 0 { - return nil - } - servers := make([]string, len(dnsServers)) - for i, server := range dnsServers { - servers[i] = withDefaultDNSPort(server) - } - return &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { - d := net.Dialer{Timeout: dialTimeout} - var err error - for _, server := range servers { - var conn net.Conn - if conn, err = d.DialContext(ctx, network, server); err == nil { - return conn, nil - } - } - return nil, err - }, - } -} - -// withDefaultDNSPort ensures a DNS server address has a port, defaulting to 53. -func withDefaultDNSPort(server string) string { - if _, _, err := net.SplitHostPort(server); err == nil { - return server - } - return net.JoinHostPort(server, "53") -} - func traceBody(v any) string { switch vt := v.(type) { case string: diff --git a/pkg/ffresty/ffresty_test.go b/pkg/ffresty/ffresty_test.go index 3325e88..6ef63fe 100644 --- a/pkg/ffresty/ffresty_test.go +++ b/pkg/ffresty/ffresty_test.go @@ -42,8 +42,9 @@ import ( "github.com/go-resty/resty/v2" "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/ffapi" + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/fftls" - "github.com/hyperledger/firefly-common/pkg/fftypes" "github.com/hyperledger/firefly-common/pkg/i18n" "github.com/hyperledger/firefly-common/pkg/metric" "github.com/sirupsen/logrus" @@ -687,6 +688,8 @@ func TestMTLSClientWithServer(t *testing.T) { var restyConfig = config.RootSection("resty") InitConfig(restyConfig) clientTLSSection := restyConfig.SubSection("tls") + // This test connects to a loopback server, which the default SSRF denylist blocks; opt out. + restyConfig.SubSection("net").Set(ffnet.CIDRDenylist, []string{}) restyConfig.Set(HTTPConfigURL, ln.Addr()) // note this does not have https:// in the URL clientTLSSection.Set(fftls.HTTPConfTLSEnabled, true) clientTLSSection.Set(fftls.HTTPConfTLSKeyFile, privateKeyFile.Name()) @@ -821,82 +824,40 @@ func TestTrace(t *testing.T) { require.Equal(t, `(binary reader)`, traceBody(strings.NewReader("data to stream"))) } -func TestWithDefaultDNSPort(t *testing.T) { - assert.Equal(t, "8.8.8.8:53", withDefaultDNSPort("8.8.8.8")) - assert.Equal(t, "8.8.8.8:5353", withDefaultDNSPort("8.8.8.8:5353")) - assert.Equal(t, "[2001:db8::1]:53", withDefaultDNSPort("2001:db8::1")) - assert.Equal(t, "[2001:db8::1]:5353", withDefaultDNSPort("[2001:db8::1]:5353")) -} - -func TestDNSResolverPrecedence(t *testing.T) { - // Nothing configured -> leave Go's default system resolver selection in place - assert.Nil(t, dnsResolver(&Config{})) - - // Programmatic resolver always wins - custom := &net.Resolver{} - assert.Same(t, custom, dnsResolver(&Config{HTTPConfig: HTTPConfig{ - Resolver: custom, - DNSServers: []string{"8.8.8.8"}, - }})) - - // DNSServers builds a pure-Go resolver - r := dnsResolver(&Config{HTTPConfig: HTTPConfig{DNSServers: []string{"8.8.8.8"}}}) - require.NotNil(t, r) - assert.True(t, r.PreferGo) - assert.NotNil(t, r.Dial) -} - -func TestDNSResolverDialFailover(t *testing.T) { - // Stand up a listener acting as the "good" DNS server - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer ln.Close() - - accepted := make(chan struct{}, 1) - go func() { - conn, acceptErr := ln.Accept() - if acceptErr == nil { - accepted <- struct{}{} - _ = conn.Close() - } - }() - - // First server is unroutable so the dialer must fail over to the live listener - r := dnsResolver(&Config{HTTPConfig: HTTPConfig{ - HTTPConnectionTimeout: fftypes.FFDuration(5 * time.Second), - DNSServers: []string{"127.0.0.1:1", ln.Addr().String()}, - }}) - require.NotNil(t, r) - - conn, err := r.Dial(context.Background(), "tcp", "ignored:53") - require.NoError(t, err) - defer conn.Close() - assert.Equal(t, ln.Addr().String(), conn.RemoteAddr().String()) - - select { - case <-accepted: - case <-time.After(5 * time.Second): - t.Fatal("DNS dial did not reach the configured server") - } -} - -func TestDNSResolverDialAllFail(t *testing.T) { - r := dnsResolver(&Config{HTTPConfig: HTTPConfig{ - HTTPConnectionTimeout: fftypes.FFDuration(250 * time.Millisecond), - DNSServers: []string{"127.0.0.1:1"}, - }}) - require.NotNil(t, r) - _, err := r.Dial(context.Background(), "tcp", "ignored:53") - assert.Error(t, err) -} - -func TestNewWithConfigDNSServersWired(t *testing.T) { +func TestNewWithConfigResolverWired(t *testing.T) { + // A programmatic resolver (e.g. built from the dns config subsection by ffdns) is + // attached to the transport's dialer rather than forcing the all-or-nothing custom client. ctx := context.Background() c := NewWithConfig(ctx, Config{HTTPConfig: HTTPConfig{ - DNSServers: []string{"8.8.8.8"}, + Resolver: &net.Resolver{PreferGo: true}, }}) require.NotNil(t, c) transport, ok := c.GetClient().Transport.(*http.Transport) require.True(t, ok) assert.NotNil(t, transport.DialContext) } + +func TestDialControlBlocksLoopbackByDefault(t *testing.T) { + // A client built from default config blocks SSRF to loopback before connecting + resetConf() + utConf.Set(HTTPConfigURL, "http://127.0.0.1:1") + c, err := New(context.Background(), utConf) + require.NoError(t, err) + _, err = c.R().Get("/") + assert.Regexp(t, "FF00261", err) +} + +func TestGenerateConfigDNSResolver(t *testing.T) { + // With dns.servers configured, GenerateConfig populates a resolver via ffdns + resetConf() + utConf.SubSection("net").Set(ffdns.DNSServers, []string{"8.8.8.8"}) + cfg, err := GenerateConfig(context.Background(), utConf) + assert.NoError(t, err) + assert.NotNil(t, cfg.Resolver) + + // With no dns.servers, no resolver is built and Go's default selection stays in place + resetConf() + cfg, err = GenerateConfig(context.Background(), utConf) + assert.NoError(t, err) + assert.Nil(t, cfg.Resolver) +} diff --git a/pkg/i18n/en_base_error_messages.go b/pkg/i18n/en_base_error_messages.go index 2cf646f..6159a13 100644 --- a/pkg/i18n/en_base_error_messages.go +++ b/pkg/i18n/en_base_error_messages.go @@ -194,4 +194,6 @@ var ( MsgInvalidLogLevel = ffe("FF00257", "Invalid log level: '%s'", http.StatusBadRequest) MsgFFExtensionsInvalid = ffe("FF00258", "Invalid extension '%s' - extensions should be RFC 3986 compliant query parameter format (e.g. x-name=value with percent-encoding for special characters)", http.StatusBadRequest) MsgFFExtensionsInvalidEncoding = ffe("FF00259", "Invalid extension key '%s' - extension keys must follow the format 'x-'", http.StatusBadRequest) + MsgInvalidCIDR = ffe("FF00260", "Invalid CIDR '%s' in DNS denylist configuration", http.StatusBadRequest) + MsgConnectionToCIDRBlocked = ffe("FF00261", "Connection to '%s' blocked by CIDR denylist (%s)", http.StatusForbidden) ) diff --git a/pkg/wsclient/wsclient.go b/pkg/wsclient/wsclient.go index 71c94d8..bf082ce 100644 --- a/pkg/wsclient/wsclient.go +++ b/pkg/wsclient/wsclient.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -22,6 +22,7 @@ import ( "encoding/base64" "fmt" "io" + "net" "net/http" "net/url" "strings" @@ -57,6 +58,10 @@ type WSConfig struct { HeartbeatInterval time.Duration `json:"heartbeatInterval,omitempty"` TLSClientConfig *tls.Config `json:"tlsClientConfig,omitempty"` ConnectionTimeout time.Duration `json:"connectionTimeout,omitempty"` + // NetDialer carries the custom DNS resolver and SSRF egress guard (CIDR denylist) for the + // underlying TCP connection. Built by GenerateConfig from the net config; cannot be set in + // JSON. Left nil for hand-built configs, in which case the default net dialer is used. + NetDialer *net.Dialer `json:"-"` // This one cannot be set in JSON - must be configured on the code interface ReceiveExt bool } @@ -143,15 +148,22 @@ func New(ctx context.Context, config *WSConfig, beforeConnect WSPreConnectHandle return nil, err } + wsDialer := &websocket.Dialer{ + ReadBufferSize: config.ReadBufferSize, + WriteBufferSize: config.WriteBufferSize, + TLSClientConfig: config.TLSClientConfig, + HandshakeTimeout: config.ConnectionTimeout, + } + // Route the TCP connection through the configured dialer so the custom DNS resolver and + // SSRF egress guard apply (TLS is still layered on top by gorilla via TLSClientConfig). + if config.NetDialer != nil { + wsDialer.NetDialContext = config.NetDialer.DialContext + } + w := &wsClient{ - ctx: ctx, - url: wsURL, - wsdialer: &websocket.Dialer{ - ReadBufferSize: config.ReadBufferSize, - WriteBufferSize: config.WriteBufferSize, - TLSClientConfig: config.TLSClientConfig, - HandshakeTimeout: config.ConnectionTimeout, - }, + ctx: ctx, + url: wsURL, + wsdialer: wsDialer, connRetry: retry.Retry{ InitialDelay: config.InitialDelay, MaximumDelay: config.MaximumDelay, diff --git a/pkg/wsclient/wsconfig.go b/pkg/wsclient/wsconfig.go index bf08795..859115b 100644 --- a/pkg/wsclient/wsconfig.go +++ b/pkg/wsclient/wsconfig.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -21,6 +21,7 @@ import ( "time" "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/ffresty" "github.com/hyperledger/firefly-common/pkg/fftls" ) @@ -109,5 +110,18 @@ func GenerateConfig(ctx context.Context, conf config.Section) (*WSConfig, error) wsConfig.TLSClientConfig = tlsClientConfig + // Build the underlying TCP dialer with the custom DNS resolver and SSRF egress guard, + // from the same "net" subsection that ffresty.InitConfig set up on this config tree. + netCfg, err := ffnet.GenerateConfig(conf.SubSection("net")) + if err != nil { + return nil, err + } + netDialer, err := ffnet.NewDialer(ctx, netCfg) + if err != nil { + return nil, err + } + netDialer.Timeout = wsConfig.ConnectionTimeout + wsConfig.NetDialer = netDialer + return wsConfig, nil } diff --git a/pkg/wsclient/wsconfig_test.go b/pkg/wsclient/wsconfig_test.go index e22d91b..363cee8 100644 --- a/pkg/wsclient/wsconfig_test.go +++ b/pkg/wsclient/wsconfig_test.go @@ -6,9 +6,12 @@ import ( "time" "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/ffresty" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var utConf = config.RootSection("ws") @@ -66,6 +69,43 @@ func TestWSConfigGenerationDefaults(t *testing.T) { assert.Equal(t, 30*time.Second, wsConfig.HeartbeatInterval) } +func TestWSConfigNetDialerDefaults(t *testing.T) { + resetConf() + + ctx := context.Background() + wsConfig, err := GenerateConfig(ctx, utConf) + require.NoError(t, err) + + // SSRF egress guard wired by default; no DNS servers => system resolver + require.NotNil(t, wsConfig.NetDialer) + assert.Nil(t, wsConfig.NetDialer.Resolver) + require.NotNil(t, wsConfig.NetDialer.Control) + assert.Error(t, wsConfig.NetDialer.Control("tcp", "169.254.169.254:80", nil)) + assert.Equal(t, defaultConnectionTimeout, wsConfig.NetDialer.Timeout) +} + +func TestWSConfigNetDialerCustom(t *testing.T) { + resetConf() + utConf.SubSection("net").Set(ffdns.DNSServers, []string{"8.8.8.8"}) + utConf.SubSection("net").Set(ffnet.CIDRDenylist, []string{}) // disable the guard + + ctx := context.Background() + wsConfig, err := GenerateConfig(ctx, utConf) + require.NoError(t, err) + require.NotNil(t, wsConfig.NetDialer) + assert.NotNil(t, wsConfig.NetDialer.Resolver) // custom DNS servers + assert.Nil(t, wsConfig.NetDialer.Control) // denylist disabled +} + +func TestWSConfigNetDialerInvalidCIDR(t *testing.T) { + resetConf() + utConf.SubSection("net").Set(ffnet.AdditionalDeniedCIDRs, []string{"not-a-cidr"}) + + ctx := context.Background() + _, err := GenerateConfig(ctx, utConf) + assert.Regexp(t, "FF00260", err) +} + func TestWSConfigTLSGenerationFail(t *testing.T) { resetConf() From 314928ca1d24ec6abc2b5e2b5c81864aba738bdc Mon Sep 17 00:00:00 2001 From: hfuss Date: Fri, 19 Jun 2026 15:35:21 -0400 Subject: [PATCH 06/10] empty default; lots of useful lists to use by default in app config Signed-off-by: hfuss --- pkg/eventstreams/webhooks_test.go | 4 - pkg/ffnet/config.go | 119 +++++++++++++++++++--------- pkg/ffnet/ffnet.go | 12 +-- pkg/ffnet/ffnet_test.go | 127 +++++++++++++++++------------- pkg/ffresty/ffresty.go | 2 +- pkg/ffresty/ffresty_test.go | 7 +- pkg/wsclient/wsconfig_test.go | 19 ++--- 7 files changed, 164 insertions(+), 126 deletions(-) diff --git a/pkg/eventstreams/webhooks_test.go b/pkg/eventstreams/webhooks_test.go index 02c6dda..f070c71 100644 --- a/pkg/eventstreams/webhooks_test.go +++ b/pkg/eventstreams/webhooks_test.go @@ -26,7 +26,6 @@ import ( "testing" "github.com/hyperledger/firefly-common/pkg/ffapi" - "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/ffresty" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/hyperledger/firefly-common/pkg/wsserver" @@ -190,9 +189,6 @@ func TestWebhooksTLS(t *testing.T) { URL: &u, TLSConfigName: &tlsConfName, }, func() { - // The test server listens on loopback, which the default SSRF egress denylist blocks; - // disable it for this webhook client so the delivery can reach the local server. - WebhookDefaultsConfig.SubSection("net").Set(ffnet.CIDRDenylist, []string{}) tls0 := TLSConfigs.ArrayEntry(0) tls0.Set(ConfigTLSConfigName, tlsConfName) tlsConf := tls0.SubSection("tls") diff --git a/pkg/ffnet/config.go b/pkg/ffnet/config.go index e879c9d..a7a206b 100644 --- a/pkg/ffnet/config.go +++ b/pkg/ffnet/config.go @@ -20,54 +20,104 @@ package ffnet import ( + "slices" + "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/ffdns" ) const ( - // CIDRDenylist fully overrides the built-in default denylist of reserved/metadata ranges - // blocked to mitigate SSRF. Leave unset to keep the secure defaults. Set to an empty list - // to disable denylisting entirely (e.g. to allow a localhost target). + // CIDRDenylist is the list of CIDR ranges to which outbound connections are blocked, as a + // core SSRF mitigation. It is empty by default — ffnet/ffresty is frequently used for private + // service-to-service traffic, so we do not presume which ranges are off-limits. Callers should + // compose an appropriate denylist from the exported building-block lists below (e.g. + // RecommendedSSRFDenylist for externally-reachable/webhook clients, or a narrower set such as + // CloudMetadataCIDRs for internal clients that still want to block IMDS). CIDRDenylist = "cidrDenylist" - // AdditionalDeniedCIDRs appends extra CIDRs on top of the effective base denylist (either - // the built-in defaults or a configured cidrDenylist override). - AdditionalDeniedCIDRs = "additionalDeniedCIDRs" ) -// DefaultDeniedCIDRs are blocked by default to mitigate SSRF: loopback, link-local (including -// the cloud metadata endpoint 169.254.169.254 and the AWS IMDS IPv6 endpoint), unspecified / -// "this host", and multicast / reserved / broadcast ranges. Private RFC1918 / IPv6 ULA ranges -// are intentionally NOT included — these dialers are commonly used for legitimate internal -// service-to-service calls, so blocking private space is deferred to network firewalls / -// zero-trust rather than baked in (callers wanting that can use additionalDeniedCIDRs). -var DefaultDeniedCIDRs = []string{ - "0.0.0.0/8", // unspecified / "this host" (RFC 1122) - "127.0.0.0/8", // IPv4 loopback - "169.254.0.0/16", // IPv4 link-local, incl. cloud metadata 169.254.169.254 - "224.0.0.0/4", // IPv4 multicast - "240.0.0.0/4", // IPv4 reserved (incl. 255.255.255.255 broadcast) - "::1/128", // IPv6 loopback - "::/128", // IPv6 unspecified - "fe80::/10", // IPv6 link-local - "fd00:ec2::254/128", // AWS IMDS IPv6 endpoint (cloud metadata) - "ff00::/8", // IPv6 multicast -} +// Exported building-block CIDR lists, grouped by category so callers can concatenate exactly the +// protection they need (see slices.Concat). Each is a distinct, non-overlapping category; the +// metadata endpoints are also called out separately for callers who want only those. +var ( + // IPv4Unspecified is the "this host on this network" range 0.0.0.0/8 (RFC 1122). 0.0.0.0 + // itself frequently resolves to localhost when dialed. + IPv4Unspecified = []string{"0.0.0.0/8"} + // IPv4Loopback is the IPv4 loopback range 127.0.0.0/8 (RFC 1122). + IPv4Loopback = []string{"127.0.0.0/8"} + // IPv4LinkLocal is the IPv4 link-local range 169.254.0.0/16 (RFC 3927). It contains the + // cloud metadata endpoint 169.254.169.254 (see CloudMetadataCIDRs). + IPv4LinkLocal = []string{"169.254.0.0/16"} + // IPv4Private are the RFC 1918 private ranges. + IPv4Private = []string{ + "10.0.0.0/8", + + "172.16.0.0/12", + "192.168.0.0/16", + } + // IPv4CGNAT is the carrier-grade NAT / shared address space 100.64.0.0/10 (RFC 6598). + IPv4CGNAT = []string{"100.64.0.0/10"} + // IPv4Multicast is the IPv4 multicast range 224.0.0.0/4 (RFC 5771). + IPv4Multicast = []string{"224.0.0.0/4"} + // IPv4Reserved is the reserved range 240.0.0.0/4 (RFC 1112), which includes the + // 255.255.255.255 limited broadcast address. + IPv4Reserved = []string{"240.0.0.0/4"} + + // IPv6Unspecified is the IPv6 unspecified address ::/128 (RFC 4291). + IPv6Unspecified = []string{"::/128"} + // IPv6Loopback is the IPv6 loopback address ::1/128 (RFC 4291). + IPv6Loopback = []string{"::1/128"} + // IPv6LinkLocal is the IPv6 link-local range fe80::/10 (RFC 4291). + IPv6LinkLocal = []string{"fe80::/10"} + // IPv6ULA is the IPv6 unique local address range fc00::/7 (RFC 4193) — the IPv6 equivalent + // of the RFC 1918 private ranges. + IPv6ULA = []string{"fc00::/7"} + // IPv6Multicast is the IPv6 multicast range ff00::/8 (RFC 4291). + IPv6Multicast = []string{"ff00::/8"} + + // CloudMetadataCIDRs are the well-known cloud instance metadata endpoints. The common + // 169.254.169.254 endpoint (AWS/GCP/Azure/OpenStack) is already within IPv4LinkLocal; this + // list additionally covers the AWS IMDS IPv6 endpoint, which is a global unicast address and + // so is NOT covered by any of the ranges above. Block this even on internal clients. + CloudMetadataCIDRs = []string{ + "169.254.169.254/32", // AWS/GCP/Azure/OpenStack IMDS (also within IPv4LinkLocal) + "fd00:ec2::254/128", // AWS IMDS IPv6 endpoint + } + + // LoopbackCIDRs blocks loopback and unspecified addresses for both IP families. + LoopbackCIDRs = slices.Concat(IPv4Loopback, IPv4Unspecified, IPv6Loopback, IPv6Unspecified) + + // LinkLocalCIDRs blocks link-local addresses (including cloud metadata) for both IP families. + LinkLocalCIDRs = slices.Concat(IPv4LinkLocal, IPv6LinkLocal, CloudMetadataCIDRs) + + // PrivateCIDRs blocks the private/internal ranges for both IP families: RFC 1918, CGNAT and + // IPv6 ULA. ffnet does NOT block these by default since service-to-service traffic commonly + // uses them — opt in only for externally-reachable clients. + PrivateCIDRs = slices.Concat(IPv4Private, IPv4CGNAT, IPv6ULA) + + // MulticastCIDRs blocks multicast/reserved/broadcast ranges for both IP families. + MulticastCIDRs = slices.Concat(IPv4Multicast, IPv4Reserved, IPv6Multicast) + + // SSRFDenylist is the recommended denylist for externally-reachable or + // user-configurable clients (e.g. webhooks): every category above. Internal service-to-service + // clients that must reach private ranges can compose a narrower list instead — e.g. + // slices.Concat(LoopbackCIDRs, LinkLocalCIDRs, MulticastCIDRs), or just CloudMetadataCIDRs. + SSRFDenylist = slices.Concat(LoopbackCIDRs, LinkLocalCIDRs, PrivateCIDRs, MulticastCIDRs) +) // Config is the combined outbound-dialer configuration: the DNS resolver settings plus the // egress CIDR denylist. type Config struct { DNS ffdns.Config - // CIDRDenylist, when non-nil, fully replaces DefaultDeniedCIDRs (an empty non-nil slice - // disables denylisting entirely). Leave nil to keep the secure defaults. + // CIDRDenylist is the set of CIDR ranges to block outbound connections to. Empty means no + // restriction. Compose it from the exported building-block lists — e.g. + // SSRFDenylist for any externally-configurable/webhook dialer. CIDRDenylist []string - // AdditionalDeniedCIDRs is appended on top of the effective base denylist. - AdditionalDeniedCIDRs []string } func InitConfig(conf config.Section) { ffdns.InitConfig(conf) conf.AddKnownKey(CIDRDenylist) - conf.AddKnownKey(AdditionalDeniedCIDRs) } func GenerateConfig(conf config.Section) (*Config, error) { @@ -76,13 +126,10 @@ func GenerateConfig(conf config.Section) (*Config, error) { return nil, err } cfg := &Config{ - DNS: *dnsCfg, - AdditionalDeniedCIDRs: conf.GetStringSlice(AdditionalDeniedCIDRs), - } - // Distinguish "unset" (keep secure defaults) from an explicit override (including an - // empty list, which disables denylisting). - if conf.IsSet(CIDRDenylist) { - cfg.CIDRDenylist = conf.GetStringSlice(CIDRDenylist) + DNS: *dnsCfg, } + // Empty by default (no egress restriction); callers opt in via config or by composing one of + // the exported denylists. + cfg.CIDRDenylist = conf.GetStringSlice(CIDRDenylist) return cfg, nil } diff --git a/pkg/ffnet/ffnet.go b/pkg/ffnet/ffnet.go index 02b62e6..45e4c66 100644 --- a/pkg/ffnet/ffnet.go +++ b/pkg/ffnet/ffnet.go @@ -45,22 +45,12 @@ func NewDialer(ctx context.Context, cfg *Config) (*net.Dialer, error) { }, nil } -// effectiveDenylist resolves the base denylist (the configured override, or the secure -// defaults when unset) and appends any additional CIDRs. -func effectiveDenylist(cfg *Config) []string { - base := cfg.CIDRDenylist - if base == nil { - base = DefaultDeniedCIDRs - } - return append(append([]string{}, base...), cfg.AdditionalDeniedCIDRs...) -} - // NewDialControl builds a net.Dialer Control function that rejects connections to any address // inside the effective CIDR denylist — the core SSRF mitigation. It runs after DNS resolution // against the actual resolved IP, so it also defeats DNS-rebinding and literal-IP bypasses. // Returns (nil, nil) when the effective denylist is empty (no restrictions). func NewDialControl(ctx context.Context, cfg *Config) (func(network, address string, c syscall.RawConn) error, error) { - entries := effectiveDenylist(cfg) + entries := cfg.CIDRDenylist if len(entries) == 0 { return nil, nil } diff --git a/pkg/ffnet/ffnet_test.go b/pkg/ffnet/ffnet_test.go index b5b3ed3..b767b87 100644 --- a/pkg/ffnet/ffnet_test.go +++ b/pkg/ffnet/ffnet_test.go @@ -19,6 +19,7 @@ package ffnet import ( "context" "net" + "slices" "testing" "github.com/hyperledger/firefly-common/pkg/config" @@ -34,62 +35,88 @@ func resetConf() { InitConfig(utConf) } -func TestNewDialControlDefaults(t *testing.T) { - // nil CIDRDenylist => secure defaults +func TestNewDialControlEmptyByDefault(t *testing.T) { + // No denylist configured => no egress restriction at all control, err := NewDialControl(context.Background(), &Config{}) require.NoError(t, err) + assert.Nil(t, control) +} + +func TestNewDialControlSSRFDenylist(t *testing.T) { + // The recommended denylist blocks every reserved/internal category + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: SSRFDenylist}) + require.NoError(t, err) require.NotNil(t, control) - // Blocked by default: loopback, link-local/cloud-metadata, unspecified, multicast/reserved - assert.Error(t, control("tcp", "127.0.0.1:8080", nil)) + assert.Error(t, control("tcp", "127.0.0.1:8080", nil)) // loopback assert.Error(t, control("tcp", "169.254.169.254:80", nil)) // cloud metadata - assert.Error(t, control("tcp", "0.0.0.0:80", nil)) - assert.Error(t, control("tcp", "[::1]:443", nil)) + assert.Error(t, control("tcp", "0.0.0.0:80", nil)) // unspecified + assert.Error(t, control("tcp", "[::1]:443", nil)) // IPv6 loopback assert.Error(t, control("tcp", "[fd00:ec2::254]:80", nil)) // AWS IMDS IPv6 assert.Error(t, control("tcp", "224.0.0.1:80", nil)) // IPv4 multicast assert.Error(t, control("tcp", "255.255.255.255:80", nil)) // IPv4 broadcast (reserved) assert.Error(t, control("tcp", "240.0.0.1:80", nil)) // IPv4 reserved assert.Error(t, control("tcp", "[ff02::1]:80", nil)) // IPv6 multicast + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // RFC1918 private + assert.Error(t, control("tcp", "192.168.1.5:80", nil)) // RFC1918 private + assert.Error(t, control("tcp", "100.64.1.1:80", nil)) // CGNAT + assert.Error(t, control("tcp", "[fc00::1]:80", nil)) // IPv6 ULA - // NOT blocked by default: public, and private RFC1918 (deferred to firewalls) + // Public addresses still allowed assert.NoError(t, control("tcp", "8.8.8.8:443", nil)) - assert.NoError(t, control("tcp", "10.1.2.3:80", nil)) - assert.NoError(t, control("tcp", "192.168.1.5:80", nil)) // IPv4-mapped IPv6 loopback is normalized and still blocked assert.Error(t, control("tcp", "[::ffff:127.0.0.1]:80", nil)) } -func TestNewDialControlOverride(t *testing.T) { - // Explicit empty override disables denylisting entirely - control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: []string{}}) +func TestNewDialControlInternalComposition(t *testing.T) { + // An internal client can compose a narrower denylist that still reaches private ranges but + // blocks loopback, link-local/cloud-metadata and multicast. + denylist := slices.Concat(LoopbackCIDRs, LinkLocalCIDRs, MulticastCIDRs) + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: denylist}) require.NoError(t, err) - assert.Nil(t, control) + require.NotNil(t, control) - // Custom override fully replaces defaults - control, err = NewDialControl(context.Background(), &Config{CIDRDenylist: []string{"10.0.0.0/8"}}) + assert.Error(t, control("tcp", "127.0.0.1:80", nil)) // loopback blocked + assert.Error(t, control("tcp", "169.254.169.254:80", nil)) // metadata blocked + assert.Error(t, control("tcp", "[fd00:ec2::254]:80", nil)) // IMDS IPv6 blocked + assert.NoError(t, control("tcp", "10.1.2.3:80", nil)) // private still reachable + assert.NoError(t, control("tcp", "192.168.1.5:80", nil)) // private still reachable +} + +func TestNewDialControlCloudMetadataOnly(t *testing.T) { + // The minimal protection: block only the cloud metadata endpoints + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: CloudMetadataCIDRs}) require.NoError(t, err) require.NotNil(t, control) - assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // now blocked - assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // defaults no longer apply + + assert.Error(t, control("tcp", "169.254.169.254:80", nil)) // metadata blocked + assert.Error(t, control("tcp", "[fd00:ec2::254]:80", nil)) // IMDS IPv6 blocked + assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // loopback reachable + assert.NoError(t, control("tcp", "169.254.1.1:80", nil)) // other link-local reachable } -func TestNewDialControlAdditional(t *testing.T) { - // Additional CIDRs extend the defaults - control, err := NewDialControl(context.Background(), &Config{AdditionalDeniedCIDRs: []string{"10.0.0.0/8"}}) +func TestNewDialControlOverride(t *testing.T) { + // Explicit empty list disables denylisting entirely + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: []string{}}) + require.NoError(t, err) + assert.Nil(t, control) + + // A custom list is used exactly as given + control, err = NewDialControl(context.Background(), &Config{CIDRDenylist: IPv4Private}) require.NoError(t, err) require.NotNil(t, control) - assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // added - assert.Error(t, control("tcp", "127.0.0.1:80", nil)) // default still applies + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // in list + assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // not in list } func TestNewDialControlInvalidCIDR(t *testing.T) { - _, err := NewDialControl(context.Background(), &Config{AdditionalDeniedCIDRs: []string{"not-a-cidr"}}) + _, err := NewDialControl(context.Background(), &Config{CIDRDenylist: []string{"not-a-cidr"}}) assert.Regexp(t, "FF00260", err) } func TestDialControlBlocksUnparseableAddress(t *testing.T) { - control, err := NewDialControl(context.Background(), &Config{}) + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: SSRFDenylist}) require.NoError(t, err) // Fail closed if an address somehow isn't a resolved IP literal assert.Regexp(t, "FF00261", control("tcp", "not-an-ip:80", nil)) @@ -97,7 +124,7 @@ func TestDialControlBlocksUnparseableAddress(t *testing.T) { func TestDialControlBareIPNoPort(t *testing.T) { // Addresses without a port still resolve their IP (SplitHostPort error fallback) - control, err := NewDialControl(context.Background(), &Config{}) + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: SSRFDenylist}) require.NoError(t, err) assert.Error(t, control("tcp", "127.0.0.1", nil)) // blocked assert.NoError(t, control("tcp", "8.8.8.8", nil)) // allowed @@ -106,7 +133,7 @@ func TestDialControlBareIPNoPort(t *testing.T) { func TestNewDialerEndToEndBlocks(t *testing.T) { // The guard fires through a real net.Dialer dial, before any connection is made - d, err := NewDialer(context.Background(), &Config{}) + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: LoopbackCIDRs}) require.NoError(t, err) require.NotNil(t, d.Control) _, err = d.DialContext(context.Background(), "tcp", "127.0.0.1:1") @@ -133,72 +160,60 @@ func TestNewDialerAllowsAndConnects(t *testing.T) { _ = conn.Close() } -func TestGenerateConfigOverrideReplacesDefaults(t *testing.T) { +func TestGenerateConfigDenylistFromConfig(t *testing.T) { resetConf() - utConf.Set(CIDRDenylist, []string{"10.0.0.0/8"}) + utConf.Set(CIDRDenylist, IPv4Private) cfg, err := GenerateConfig(utConf) require.NoError(t, err) - assert.Equal(t, []string{"10.0.0.0/8"}, cfg.CIDRDenylist) + assert.Equal(t, IPv4Private, cfg.CIDRDenylist) control, err := NewDialControl(context.Background(), cfg) require.NoError(t, err) require.NotNil(t, control) - assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // override entry blocked - assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // defaults replaced, loopback allowed + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // configured entry blocked + assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // loopback not in the configured list } func TestNewDialer(t *testing.T) { - // Default config => dialer with the SSRF guard wired (resolver nil since no DNS servers) - d, err := NewDialer(context.Background(), &Config{}) + // SSRF denylist => dialer with the egress guard wired (resolver nil since no DNS servers) + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: SSRFDenylist}) require.NoError(t, err) require.NotNil(t, d) assert.Nil(t, d.Resolver) require.NotNil(t, d.Control) assert.Error(t, d.Control("tcp", "127.0.0.1:80", nil)) - // DNS servers => resolver attached; empty denylist => no control + // DNS servers => resolver attached; no denylist => no control d, err = NewDialer(context.Background(), &Config{ - DNS: ffdns.Config{Servers: []string{"8.8.8.8"}}, - CIDRDenylist: []string{}, + DNS: ffdns.Config{Servers: []string{"8.8.8.8"}}, }) require.NoError(t, err) require.NotNil(t, d.Resolver) assert.Nil(t, d.Control) // Invalid CIDR propagates as an error - _, err = NewDialer(context.Background(), &Config{AdditionalDeniedCIDRs: []string{"bad"}}) + _, err = NewDialer(context.Background(), &Config{CIDRDenylist: []string{"bad"}}) assert.Regexp(t, "FF00260", err) } func TestGenerateConfigDenylistSemantics(t *testing.T) { - // Unset cidrDenylist => defaults active (control blocks loopback) + // Unset cidrDenylist => empty, no guard resetConf() cfg, err := GenerateConfig(utConf) require.NoError(t, err) - assert.Nil(t, cfg.CIDRDenylist) + assert.Empty(t, cfg.CIDRDenylist) control, err := NewDialControl(context.Background(), cfg) require.NoError(t, err) - require.NotNil(t, control) - assert.Error(t, control("tcp", "127.0.0.1:80", nil)) + assert.Nil(t, control) - // Explicitly-set empty cidrDenylist => disabled + // Configured denylist => guard active resetConf() - utConf.Set(CIDRDenylist, []string{}) + utConf.Set(CIDRDenylist, SSRFDenylist) cfg, err = GenerateConfig(utConf) require.NoError(t, err) - assert.NotNil(t, cfg.CIDRDenylist) + assert.NotEmpty(t, cfg.CIDRDenylist) control, err = NewDialControl(context.Background(), cfg) require.NoError(t, err) - assert.Nil(t, control) -} - -func TestGenerateConfigDNSAndAdditional(t *testing.T) { - resetConf() - utConf.Set(ffdns.DNSServers, []string{"8.8.8.8"}) - utConf.Set(AdditionalDeniedCIDRs, []string{"10.0.0.0/8"}) - cfg, err := GenerateConfig(utConf) - require.NoError(t, err) - assert.Equal(t, []string{"8.8.8.8"}, cfg.DNS.Servers) - assert.Equal(t, []string{"10.0.0.0/8"}, cfg.AdditionalDeniedCIDRs) - assert.NotNil(t, cfg.Resolver()) + require.NotNil(t, control) + assert.Error(t, control("tcp", "127.0.0.1:80", nil)) } diff --git a/pkg/ffresty/ffresty.go b/pkg/ffresty/ffresty.go index c0b7866..3d2ea09 100644 --- a/pkg/ffresty/ffresty.go +++ b/pkg/ffresty/ffresty.go @@ -221,7 +221,7 @@ func NewWithConfig(ctx context.Context, ffrestyConfig Config) (client *resty.Cli if ffrestyConfig.Resolver != nil { dialer.Resolver = ffrestyConfig.Resolver } - // SSRF CIDR-denylist guard, checked against the resolved IP just before connect. + // CIDR-denylist guard for SSRF and/or high-trust, checked against the resolved IP just before connect. if ffrestyConfig.DialControl != nil { dialer.Control = ffrestyConfig.DialControl } diff --git a/pkg/ffresty/ffresty_test.go b/pkg/ffresty/ffresty_test.go index 6ef63fe..c9f3bb6 100644 --- a/pkg/ffresty/ffresty_test.go +++ b/pkg/ffresty/ffresty_test.go @@ -688,8 +688,6 @@ func TestMTLSClientWithServer(t *testing.T) { var restyConfig = config.RootSection("resty") InitConfig(restyConfig) clientTLSSection := restyConfig.SubSection("tls") - // This test connects to a loopback server, which the default SSRF denylist blocks; opt out. - restyConfig.SubSection("net").Set(ffnet.CIDRDenylist, []string{}) restyConfig.Set(HTTPConfigURL, ln.Addr()) // note this does not have https:// in the URL clientTLSSection.Set(fftls.HTTPConfTLSEnabled, true) clientTLSSection.Set(fftls.HTTPConfTLSKeyFile, privateKeyFile.Name()) @@ -837,9 +835,10 @@ func TestNewWithConfigResolverWired(t *testing.T) { assert.NotNil(t, transport.DialContext) } -func TestDialControlBlocksLoopbackByDefault(t *testing.T) { - // A client built from default config blocks SSRF to loopback before connecting +func TestDialControlBlocksLoopbackWhenConfigured(t *testing.T) { + // With an SSRF denylist configured, the client blocks loopback before connecting resetConf() + utConf.SubSection("net").Set(ffnet.CIDRDenylist, ffnet.SSRFDenylist) utConf.Set(HTTPConfigURL, "http://127.0.0.1:1") c, err := New(context.Background(), utConf) require.NoError(t, err) diff --git a/pkg/wsclient/wsconfig_test.go b/pkg/wsclient/wsconfig_test.go index 363cee8..c480fae 100644 --- a/pkg/wsclient/wsconfig_test.go +++ b/pkg/wsclient/wsconfig_test.go @@ -76,34 +76,25 @@ func TestWSConfigNetDialerDefaults(t *testing.T) { wsConfig, err := GenerateConfig(ctx, utConf) require.NoError(t, err) - // SSRF egress guard wired by default; no DNS servers => system resolver + // No egress denylist or DNS servers configured by default => no guard, system resolver require.NotNil(t, wsConfig.NetDialer) assert.Nil(t, wsConfig.NetDialer.Resolver) - require.NotNil(t, wsConfig.NetDialer.Control) - assert.Error(t, wsConfig.NetDialer.Control("tcp", "169.254.169.254:80", nil)) + assert.Nil(t, wsConfig.NetDialer.Control) assert.Equal(t, defaultConnectionTimeout, wsConfig.NetDialer.Timeout) } func TestWSConfigNetDialerCustom(t *testing.T) { resetConf() utConf.SubSection("net").Set(ffdns.DNSServers, []string{"8.8.8.8"}) - utConf.SubSection("net").Set(ffnet.CIDRDenylist, []string{}) // disable the guard + utConf.SubSection("net").Set(ffnet.CIDRDenylist, ffnet.SSRFDenylist) // opt in to the egress guard ctx := context.Background() wsConfig, err := GenerateConfig(ctx, utConf) require.NoError(t, err) require.NotNil(t, wsConfig.NetDialer) assert.NotNil(t, wsConfig.NetDialer.Resolver) // custom DNS servers - assert.Nil(t, wsConfig.NetDialer.Control) // denylist disabled -} - -func TestWSConfigNetDialerInvalidCIDR(t *testing.T) { - resetConf() - utConf.SubSection("net").Set(ffnet.AdditionalDeniedCIDRs, []string{"not-a-cidr"}) - - ctx := context.Background() - _, err := GenerateConfig(ctx, utConf) - assert.Regexp(t, "FF00260", err) + require.NotNil(t, wsConfig.NetDialer.Control) // denylist active + assert.Error(t, wsConfig.NetDialer.Control("tcp", "169.254.169.254:80", nil)) } func TestWSConfigTLSGenerationFail(t *testing.T) { From 9ef1a4bce5ebe6ac76c8d91213428071ec3c790c Mon Sep 17 00:00:00 2001 From: hfuss Date: Sun, 21 Jun 2026 13:36:55 -0400 Subject: [PATCH 07/10] PR feedback; separated dns from net config and simplified tests Signed-off-by: hfuss --- pkg/ffdns/ffdns_test.go | 2 +- pkg/ffnet/config.go | 80 +--------------------------- pkg/ffnet/ffnet.go | 14 ++--- pkg/ffnet/ffnet_test.go | 83 ++++++++++++++++++------------ pkg/ffresty/config.go | 11 +++- pkg/ffresty/config_test.go | 2 +- pkg/ffresty/ffresty_test.go | 7 ++- pkg/i18n/en_base_error_messages.go | 2 +- pkg/wsclient/wsconfig.go | 10 +++- pkg/wsclient/wsconfig_test.go | 11 +++- 10 files changed, 93 insertions(+), 129 deletions(-) diff --git a/pkg/ffdns/ffdns_test.go b/pkg/ffdns/ffdns_test.go index f35e9df..2355709 100644 --- a/pkg/ffdns/ffdns_test.go +++ b/pkg/ffdns/ffdns_test.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // diff --git a/pkg/ffnet/config.go b/pkg/ffnet/config.go index a7a206b..35213a2 100644 --- a/pkg/ffnet/config.go +++ b/pkg/ffnet/config.go @@ -20,8 +20,6 @@ package ffnet import ( - "slices" - "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/ffdns" ) @@ -36,79 +34,9 @@ const ( CIDRDenylist = "cidrDenylist" ) -// Exported building-block CIDR lists, grouped by category so callers can concatenate exactly the -// protection they need (see slices.Concat). Each is a distinct, non-overlapping category; the -// metadata endpoints are also called out separately for callers who want only those. -var ( - // IPv4Unspecified is the "this host on this network" range 0.0.0.0/8 (RFC 1122). 0.0.0.0 - // itself frequently resolves to localhost when dialed. - IPv4Unspecified = []string{"0.0.0.0/8"} - // IPv4Loopback is the IPv4 loopback range 127.0.0.0/8 (RFC 1122). - IPv4Loopback = []string{"127.0.0.0/8"} - // IPv4LinkLocal is the IPv4 link-local range 169.254.0.0/16 (RFC 3927). It contains the - // cloud metadata endpoint 169.254.169.254 (see CloudMetadataCIDRs). - IPv4LinkLocal = []string{"169.254.0.0/16"} - // IPv4Private are the RFC 1918 private ranges. - IPv4Private = []string{ - "10.0.0.0/8", - - "172.16.0.0/12", - "192.168.0.0/16", - } - // IPv4CGNAT is the carrier-grade NAT / shared address space 100.64.0.0/10 (RFC 6598). - IPv4CGNAT = []string{"100.64.0.0/10"} - // IPv4Multicast is the IPv4 multicast range 224.0.0.0/4 (RFC 5771). - IPv4Multicast = []string{"224.0.0.0/4"} - // IPv4Reserved is the reserved range 240.0.0.0/4 (RFC 1112), which includes the - // 255.255.255.255 limited broadcast address. - IPv4Reserved = []string{"240.0.0.0/4"} - - // IPv6Unspecified is the IPv6 unspecified address ::/128 (RFC 4291). - IPv6Unspecified = []string{"::/128"} - // IPv6Loopback is the IPv6 loopback address ::1/128 (RFC 4291). - IPv6Loopback = []string{"::1/128"} - // IPv6LinkLocal is the IPv6 link-local range fe80::/10 (RFC 4291). - IPv6LinkLocal = []string{"fe80::/10"} - // IPv6ULA is the IPv6 unique local address range fc00::/7 (RFC 4193) — the IPv6 equivalent - // of the RFC 1918 private ranges. - IPv6ULA = []string{"fc00::/7"} - // IPv6Multicast is the IPv6 multicast range ff00::/8 (RFC 4291). - IPv6Multicast = []string{"ff00::/8"} - - // CloudMetadataCIDRs are the well-known cloud instance metadata endpoints. The common - // 169.254.169.254 endpoint (AWS/GCP/Azure/OpenStack) is already within IPv4LinkLocal; this - // list additionally covers the AWS IMDS IPv6 endpoint, which is a global unicast address and - // so is NOT covered by any of the ranges above. Block this even on internal clients. - CloudMetadataCIDRs = []string{ - "169.254.169.254/32", // AWS/GCP/Azure/OpenStack IMDS (also within IPv4LinkLocal) - "fd00:ec2::254/128", // AWS IMDS IPv6 endpoint - } - - // LoopbackCIDRs blocks loopback and unspecified addresses for both IP families. - LoopbackCIDRs = slices.Concat(IPv4Loopback, IPv4Unspecified, IPv6Loopback, IPv6Unspecified) - - // LinkLocalCIDRs blocks link-local addresses (including cloud metadata) for both IP families. - LinkLocalCIDRs = slices.Concat(IPv4LinkLocal, IPv6LinkLocal, CloudMetadataCIDRs) - - // PrivateCIDRs blocks the private/internal ranges for both IP families: RFC 1918, CGNAT and - // IPv6 ULA. ffnet does NOT block these by default since service-to-service traffic commonly - // uses them — opt in only for externally-reachable clients. - PrivateCIDRs = slices.Concat(IPv4Private, IPv4CGNAT, IPv6ULA) - - // MulticastCIDRs blocks multicast/reserved/broadcast ranges for both IP families. - MulticastCIDRs = slices.Concat(IPv4Multicast, IPv4Reserved, IPv6Multicast) - - // SSRFDenylist is the recommended denylist for externally-reachable or - // user-configurable clients (e.g. webhooks): every category above. Internal service-to-service - // clients that must reach private ranges can compose a narrower list instead — e.g. - // slices.Concat(LoopbackCIDRs, LinkLocalCIDRs, MulticastCIDRs), or just CloudMetadataCIDRs. - SSRFDenylist = slices.Concat(LoopbackCIDRs, LinkLocalCIDRs, PrivateCIDRs, MulticastCIDRs) -) - // Config is the combined outbound-dialer configuration: the DNS resolver settings plus the // egress CIDR denylist. type Config struct { - DNS ffdns.Config // CIDRDenylist is the set of CIDR ranges to block outbound connections to. Empty means no // restriction. Compose it from the exported building-block lists — e.g. // SSRFDenylist for any externally-configurable/webhook dialer. @@ -121,13 +49,7 @@ func InitConfig(conf config.Section) { } func GenerateConfig(conf config.Section) (*Config, error) { - dnsCfg, err := ffdns.GenerateConfig(conf) - if err != nil { - return nil, err - } - cfg := &Config{ - DNS: *dnsCfg, - } + cfg := &Config{} // Empty by default (no egress restriction); callers opt in via config or by composing one of // the exported denylists. cfg.CIDRDenylist = conf.GetStringSlice(CIDRDenylist) diff --git a/pkg/ffnet/ffnet.go b/pkg/ffnet/ffnet.go index 45e4c66..93446b1 100644 --- a/pkg/ffnet/ffnet.go +++ b/pkg/ffnet/ffnet.go @@ -21,35 +21,31 @@ import ( "net" "syscall" - "github.com/hyperledger/firefly-common/pkg/ffdns" "github.com/hyperledger/firefly-common/pkg/i18n" ) -// Resolver returns the custom DNS resolver for this config, or nil to use the system resolver. -func (cfg *Config) Resolver() *net.Resolver { - return ffdns.NewResolverWithConfig(&cfg.DNS) -} - // NewDialer builds a *net.Dialer wired with the custom DNS resolver (if any) and the SSRF // egress guard (on by default). The caller is responsible for setting Timeout / KeepAlive to // suit its protocol. Exported so any dialer-based client — HTTP, WebSocket, etc. — can apply // identical outbound protection from the same config. -func NewDialer(ctx context.Context, cfg *Config) (*net.Dialer, error) { +func NewDialer(ctx context.Context, cfg *Config, resolver *net.Resolver) (*net.Dialer, error) { control, err := NewDialControl(ctx, cfg) if err != nil { return nil, err } return &net.Dialer{ - Resolver: cfg.Resolver(), + Resolver: resolver, Control: control, }, nil } +type DialControl func(network, address string, c syscall.RawConn) error + // NewDialControl builds a net.Dialer Control function that rejects connections to any address // inside the effective CIDR denylist — the core SSRF mitigation. It runs after DNS resolution // against the actual resolved IP, so it also defeats DNS-rebinding and literal-IP bypasses. // Returns (nil, nil) when the effective denylist is empty (no restrictions). -func NewDialControl(ctx context.Context, cfg *Config) (func(network, address string, c syscall.RawConn) error, error) { +func NewDialControl(ctx context.Context, cfg *Config) (DialControl, error) { entries := cfg.CIDRDenylist if len(entries) == 0 { return nil, nil diff --git a/pkg/ffnet/ffnet_test.go b/pkg/ffnet/ffnet_test.go index b767b87..77aedda 100644 --- a/pkg/ffnet/ffnet_test.go +++ b/pkg/ffnet/ffnet_test.go @@ -19,7 +19,6 @@ package ffnet import ( "context" "net" - "slices" "testing" "github.com/hyperledger/firefly-common/pkg/config" @@ -30,6 +29,25 @@ import ( var utConf = config.RootSection("net_unit_tests") +var testSSRDenylist = []string{ + "0.0.0.0/8", // unspecified / "this host" (RFC 1122) + "127.0.0.0/8", // IPv4 loopback + "169.254.0.0/16", // IPv4 link-local, incl. cloud metadata 169.254.169.254 + "10.0.0.0/8", // IPv4 private RFC1918 + "172.16.0.0/12", // IPv4 private RFC1918 + "192.168.0.0/16", // IPv4 private RFC1918 + "100.64.0.0/10", // IPv4 CGNAT + "224.0.0.0/4", // IPv4 multicast + "240.0.0.0/4", // IPv4 reserved (incl. 255.255.255.255 broadcast) + "fc00::/7", // IPv6 ULA + "fe00::/8", // IPv6 private RFC4193 + "ff00::/8", // IPv6 reserved + "::ffff:127.0.0.1/128", // IPv4-mapped IPv6 loopback + "::1/128", // IPv6 loopback + "::/0", // IPv6 unspecified + "::/128", // IPv6 unspecified +} + func resetConf() { config.RootConfigReset() InitConfig(utConf) @@ -43,8 +61,8 @@ func TestNewDialControlEmptyByDefault(t *testing.T) { } func TestNewDialControlSSRFDenylist(t *testing.T) { - // The recommended denylist blocks every reserved/internal category - control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: SSRFDenylist}) + // The test denylist blocks every reserved/internal category + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: testSSRDenylist}) require.NoError(t, err) require.NotNil(t, control) @@ -69,24 +87,13 @@ func TestNewDialControlSSRFDenylist(t *testing.T) { assert.Error(t, control("tcp", "[::ffff:127.0.0.1]:80", nil)) } -func TestNewDialControlInternalComposition(t *testing.T) { - // An internal client can compose a narrower denylist that still reaches private ranges but - // blocks loopback, link-local/cloud-metadata and multicast. - denylist := slices.Concat(LoopbackCIDRs, LinkLocalCIDRs, MulticastCIDRs) - control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: denylist}) - require.NoError(t, err) - require.NotNil(t, control) - - assert.Error(t, control("tcp", "127.0.0.1:80", nil)) // loopback blocked - assert.Error(t, control("tcp", "169.254.169.254:80", nil)) // metadata blocked - assert.Error(t, control("tcp", "[fd00:ec2::254]:80", nil)) // IMDS IPv6 blocked - assert.NoError(t, control("tcp", "10.1.2.3:80", nil)) // private still reachable - assert.NoError(t, control("tcp", "192.168.1.5:80", nil)) // private still reachable -} - func TestNewDialControlCloudMetadataOnly(t *testing.T) { // The minimal protection: block only the cloud metadata endpoints - control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: CloudMetadataCIDRs}) + cloudMetadataCIDRs := []string{ + "169.254.169.254/32", + "fd00:ec2::254/128", + } + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: cloudMetadataCIDRs}) require.NoError(t, err) require.NotNil(t, control) @@ -103,7 +110,12 @@ func TestNewDialControlOverride(t *testing.T) { assert.Nil(t, control) // A custom list is used exactly as given - control, err = NewDialControl(context.Background(), &Config{CIDRDenylist: IPv4Private}) + ipv4PrivateCIDRs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + } + control, err = NewDialControl(context.Background(), &Config{CIDRDenylist: ipv4PrivateCIDRs}) require.NoError(t, err) require.NotNil(t, control) assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // in list @@ -116,7 +128,7 @@ func TestNewDialControlInvalidCIDR(t *testing.T) { } func TestDialControlBlocksUnparseableAddress(t *testing.T) { - control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: SSRFDenylist}) + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: testSSRDenylist}) require.NoError(t, err) // Fail closed if an address somehow isn't a resolved IP literal assert.Regexp(t, "FF00261", control("tcp", "not-an-ip:80", nil)) @@ -124,7 +136,7 @@ func TestDialControlBlocksUnparseableAddress(t *testing.T) { func TestDialControlBareIPNoPort(t *testing.T) { // Addresses without a port still resolve their IP (SplitHostPort error fallback) - control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: SSRFDenylist}) + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: testSSRDenylist}) require.NoError(t, err) assert.Error(t, control("tcp", "127.0.0.1", nil)) // blocked assert.NoError(t, control("tcp", "8.8.8.8", nil)) // allowed @@ -133,7 +145,10 @@ func TestDialControlBareIPNoPort(t *testing.T) { func TestNewDialerEndToEndBlocks(t *testing.T) { // The guard fires through a real net.Dialer dial, before any connection is made - d, err := NewDialer(context.Background(), &Config{CIDRDenylist: LoopbackCIDRs}) + loopbackCIDRs := []string{ + "127.0.0.0/8", + } + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: loopbackCIDRs}, nil) require.NoError(t, err) require.NotNil(t, d.Control) _, err = d.DialContext(context.Background(), "tcp", "127.0.0.1:1") @@ -151,7 +166,7 @@ func TestNewDialerAllowsAndConnects(t *testing.T) { } }() - d, err := NewDialer(context.Background(), &Config{CIDRDenylist: []string{}}) + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: []string{}}, nil) require.NoError(t, err) assert.Nil(t, d.Control) conn, err := d.DialContext(context.Background(), "tcp", ln.Addr().String()) @@ -162,10 +177,15 @@ func TestNewDialerAllowsAndConnects(t *testing.T) { func TestGenerateConfigDenylistFromConfig(t *testing.T) { resetConf() - utConf.Set(CIDRDenylist, IPv4Private) + ipv4PrivateCIDRs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + } + utConf.Set(CIDRDenylist, ipv4PrivateCIDRs) cfg, err := GenerateConfig(utConf) require.NoError(t, err) - assert.Equal(t, IPv4Private, cfg.CIDRDenylist) + assert.Equal(t, ipv4PrivateCIDRs, cfg.CIDRDenylist) control, err := NewDialControl(context.Background(), cfg) require.NoError(t, err) @@ -176,23 +196,22 @@ func TestGenerateConfigDenylistFromConfig(t *testing.T) { func TestNewDialer(t *testing.T) { // SSRF denylist => dialer with the egress guard wired (resolver nil since no DNS servers) - d, err := NewDialer(context.Background(), &Config{CIDRDenylist: SSRFDenylist}) + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: testSSRDenylist}, nil) require.NoError(t, err) require.NotNil(t, d) assert.Nil(t, d.Resolver) require.NotNil(t, d.Control) assert.Error(t, d.Control("tcp", "127.0.0.1:80", nil)) + resolver := ffdns.NewResolverWithConfig(&ffdns.Config{Servers: []string{"8.8.8.8"}}) // DNS servers => resolver attached; no denylist => no control - d, err = NewDialer(context.Background(), &Config{ - DNS: ffdns.Config{Servers: []string{"8.8.8.8"}}, - }) + d, err = NewDialer(context.Background(), &Config{}, resolver) require.NoError(t, err) require.NotNil(t, d.Resolver) assert.Nil(t, d.Control) // Invalid CIDR propagates as an error - _, err = NewDialer(context.Background(), &Config{CIDRDenylist: []string{"bad"}}) + _, err = NewDialer(context.Background(), &Config{CIDRDenylist: []string{"bad"}}, resolver) assert.Regexp(t, "FF00260", err) } @@ -208,7 +227,7 @@ func TestGenerateConfigDenylistSemantics(t *testing.T) { // Configured denylist => guard active resetConf() - utConf.Set(CIDRDenylist, SSRFDenylist) + utConf.Set(CIDRDenylist, testSSRDenylist) cfg, err = GenerateConfig(utConf) require.NoError(t, err) assert.NotEmpty(t, cfg.CIDRDenylist) diff --git a/pkg/ffresty/config.go b/pkg/ffresty/config.go index 87d5b5e..05bbef2 100644 --- a/pkg/ffresty/config.go +++ b/pkg/ffresty/config.go @@ -20,6 +20,7 @@ import ( "context" "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/hyperledger/firefly-common/pkg/fftypes" @@ -122,6 +123,9 @@ func InitConfig(conf config.Section) { tlsConfig := conf.SubSection("tls") fftls.InitTLSConfig(tlsConfig) + dnsConfig := conf.SubSection("dns") + ffdns.InitConfig(dnsConfig) + netConfig := conf.SubSection("net") ffnet.InitConfig(netConfig) } @@ -161,11 +165,16 @@ func GenerateConfig(ctx context.Context, conf config.Section) (*Config, error) { ffrestyConfig.TLSClientConfig = tlsClientConfig + dnsCfg, err := ffdns.GenerateConfig(conf.SubSection("dns")) + if err != nil { + return nil, err + } + ffrestyConfig.Resolver = ffdns.NewResolverWithConfig(dnsCfg) + netCfg, err := ffnet.GenerateConfig(conf.SubSection("net")) if err != nil { return nil, err } - ffrestyConfig.Resolver = netCfg.Resolver() dialControl, err := ffnet.NewDialControl(ctx, netCfg) if err != nil { return nil, err diff --git a/pkg/ffresty/config_test.go b/pkg/ffresty/config_test.go index 35936c3..f4a20a6 100644 --- a/pkg/ffresty/config_test.go +++ b/pkg/ffresty/config_test.go @@ -48,7 +48,7 @@ func TestWSConfigGeneration(t *testing.T) { utConf.Set(HTTPTLSHandshakeTimeout, 1) utConf.Set(HTTPExpectContinueTimeout, 1) utConf.Set(HTTPPassthroughHeadersEnabled, true) - utConf.SubSection("net").Set(ffdns.DNSServers, []string{"8.8.8.8", "1.1.1.1:53"}) + utConf.SubSection("dns").Set(ffdns.DNSServers, []string{"8.8.8.8", "1.1.1.1:53"}) ctx := context.Background() config, err := GenerateConfig(ctx, utConf) diff --git a/pkg/ffresty/ffresty_test.go b/pkg/ffresty/ffresty_test.go index c9f3bb6..ff0382a 100644 --- a/pkg/ffresty/ffresty_test.go +++ b/pkg/ffresty/ffresty_test.go @@ -838,7 +838,10 @@ func TestNewWithConfigResolverWired(t *testing.T) { func TestDialControlBlocksLoopbackWhenConfigured(t *testing.T) { // With an SSRF denylist configured, the client blocks loopback before connecting resetConf() - utConf.SubSection("net").Set(ffnet.CIDRDenylist, ffnet.SSRFDenylist) + ssrfDenylist := []string{ + "127.0.0.0/8", + } + utConf.SubSection("net").Set(ffnet.CIDRDenylist, ssrfDenylist) utConf.Set(HTTPConfigURL, "http://127.0.0.1:1") c, err := New(context.Background(), utConf) require.NoError(t, err) @@ -849,7 +852,7 @@ func TestDialControlBlocksLoopbackWhenConfigured(t *testing.T) { func TestGenerateConfigDNSResolver(t *testing.T) { // With dns.servers configured, GenerateConfig populates a resolver via ffdns resetConf() - utConf.SubSection("net").Set(ffdns.DNSServers, []string{"8.8.8.8"}) + utConf.SubSection("dns").Set(ffdns.DNSServers, []string{"8.8.8.8"}) cfg, err := GenerateConfig(context.Background(), utConf) assert.NoError(t, err) assert.NotNil(t, cfg.Resolver) diff --git a/pkg/i18n/en_base_error_messages.go b/pkg/i18n/en_base_error_messages.go index 6159a13..b251b30 100644 --- a/pkg/i18n/en_base_error_messages.go +++ b/pkg/i18n/en_base_error_messages.go @@ -194,6 +194,6 @@ var ( MsgInvalidLogLevel = ffe("FF00257", "Invalid log level: '%s'", http.StatusBadRequest) MsgFFExtensionsInvalid = ffe("FF00258", "Invalid extension '%s' - extensions should be RFC 3986 compliant query parameter format (e.g. x-name=value with percent-encoding for special characters)", http.StatusBadRequest) MsgFFExtensionsInvalidEncoding = ffe("FF00259", "Invalid extension key '%s' - extension keys must follow the format 'x-'", http.StatusBadRequest) - MsgInvalidCIDR = ffe("FF00260", "Invalid CIDR '%s' in DNS denylist configuration", http.StatusBadRequest) + MsgInvalidCIDR = ffe("FF00260", "Invalid CIDR '%s' in denylist configuration", http.StatusBadRequest) MsgConnectionToCIDRBlocked = ffe("FF00261", "Connection to '%s' blocked by CIDR denylist (%s)", http.StatusForbidden) ) diff --git a/pkg/wsclient/wsconfig.go b/pkg/wsclient/wsconfig.go index 859115b..1d3d0fd 100644 --- a/pkg/wsclient/wsconfig.go +++ b/pkg/wsclient/wsconfig.go @@ -21,6 +21,7 @@ import ( "time" "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/ffresty" "github.com/hyperledger/firefly-common/pkg/fftls" @@ -116,7 +117,14 @@ func GenerateConfig(ctx context.Context, conf config.Section) (*WSConfig, error) if err != nil { return nil, err } - netDialer, err := ffnet.NewDialer(ctx, netCfg) + + dnsCfg, err := ffdns.GenerateConfig(conf.SubSection("dns")) + if err != nil { + return nil, err + } + resolver := ffdns.NewResolverWithConfig(dnsCfg) + + netDialer, err := ffnet.NewDialer(ctx, netCfg, resolver) if err != nil { return nil, err } diff --git a/pkg/wsclient/wsconfig_test.go b/pkg/wsclient/wsconfig_test.go index c480fae..064cf82 100644 --- a/pkg/wsclient/wsconfig_test.go +++ b/pkg/wsclient/wsconfig_test.go @@ -85,8 +85,15 @@ func TestWSConfigNetDialerDefaults(t *testing.T) { func TestWSConfigNetDialerCustom(t *testing.T) { resetConf() - utConf.SubSection("net").Set(ffdns.DNSServers, []string{"8.8.8.8"}) - utConf.SubSection("net").Set(ffnet.CIDRDenylist, ffnet.SSRFDenylist) // opt in to the egress guard + ssrfDenylist := []string{ + "0.0.0.0/8", + "127.0.0.0/8", + "169.254.0.0/16", + "224.0.0.0/4", + "240.0.0.0/4", + } + utConf.SubSection("dns").Set(ffdns.DNSServers, []string{"8.8.8.8"}) + utConf.SubSection("net").Set(ffnet.CIDRDenylist, ssrfDenylist) // opt in to the egress guard ctx := context.Background() wsConfig, err := GenerateConfig(ctx, utConf) From 60f40154f4d89e78e3c7e9161e8c061a0b8cea73 Mon Sep 17 00:00:00 2001 From: hfuss Date: Sun, 21 Jun 2026 14:44:09 -0400 Subject: [PATCH 08/10] clean up comments and fix names for config reference Signed-off-by: hfuss --- pkg/ffdns/config.go | 10 ++++----- pkg/ffdns/ffdns.go | 41 ++++++++++++++++++++++++----------- pkg/ffnet/config.go | 23 +++++++------------- pkg/ffnet/ffnet.go | 9 ++++---- pkg/ffnet/ffnet_test.go | 6 ++--- pkg/ffresty/ffresty_test.go | 2 +- pkg/wsclient/wsconfig_test.go | 2 +- 7 files changed, 50 insertions(+), 43 deletions(-) diff --git a/pkg/ffdns/config.go b/pkg/ffdns/config.go index 2db50f1..af7833f 100644 --- a/pkg/ffdns/config.go +++ b/pkg/ffdns/config.go @@ -23,11 +23,11 @@ import ( ) const ( - // DNSServers an optional list of DNS server addresses (host or host:port, port defaults - // to 53). Setting this forces use of Go's built-in resolver rather than the system one. - DNSServers = "dnsServers" - // DNSTimeout the dial timeout when contacting a configured DNS server - DNSTimeout = "dnsTimeout" + // Servers an optional list of DNS server addresses (host or host:port, port defaults + // to 53). Setting this forces use of Go's built-in resolver. + DNSServers = "servers" + // Timeout the dial timeout when contacting a configured DNS server + DNSTimeout = "timeout" ) type Config struct { diff --git a/pkg/ffdns/ffdns.go b/pkg/ffdns/ffdns.go index dfe5bc4..3f7c5c1 100644 --- a/pkg/ffdns/ffdns.go +++ b/pkg/ffdns/ffdns.go @@ -43,11 +43,11 @@ func EnableResolverMetrics(ctx context.Context, metricsRegistry metric.MetricsRe metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSErrorsTotal, "DNS errors", []string{"server", "error"}, false) } -// NewDNSResolver builds a pure-Go *net.Resolver that dials the given DNS servers -// (each host or host:port, port defaulting to 53) in order, failing over to the -// next on error. Returns nil when no servers are given (use the system resolver). +// NewDNSResolver builds a pure-Go *net.Resolver for metrics instructmentation, custom timeouts, and/or custom servers. +// The resolver will dial the given DNS servers (each host or host:port, port defaulting to 53) in order, failing over to the +// next on error. Returns nil if none of the customizations (metrics, timeout, or servers) are enabeld. // Exported so non-ffresty dialers — e.g. a WebSocket dialer — can honour the same -// dnsServers config as the HTTP client. +// DNS config as the HTTP client. func NewResolver(config config.Section) *net.Resolver { cfg, err := GenerateConfig(config) if err != nil { @@ -58,23 +58,38 @@ func NewResolver(config config.Section) *net.Resolver { } func NewResolverWithConfig(cfg *Config) *net.Resolver { - if len(cfg.Servers) == 0 { - return nil // TODO no matter what do we want / need DNS metrics ? + var servers []string + if len(cfg.Servers) > 0 { + servers = make([]string, len(cfg.Servers)) + for i, server := range cfg.Servers { + servers[i] = withDefaultDNSPort(server) + } } - servers := make([]string, len(cfg.Servers)) - for i, server := range cfg.Servers { - servers[i] = withDefaultDNSPort(server) + + // If we have nothing to layer on top of the system resolver — no configured servers, no + // dial timeout, and metrics disabled — leave it untouched (callers treat nil as "use the + // system resolver"). Returning a resolver here would force Go's built-in resolver + // (PreferGo) in deployments that haven't opted into any of these. + if len(servers) == 0 && cfg.Timeout <= 0 && metricsManager == nil { + return nil } + return &net.Resolver{ PreferGo: true, - Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { d := net.Dialer{Timeout: cfg.Timeout} + // When no servers are explicitly configured, wrap Go's built-in resolver: it has + // already selected a nameserver from the system config (resolv.conf) and passes it + // as address, so we dial that and still apply our timeout and metrics. + dialServers := servers + if len(dialServers) == 0 { + dialServers = []string{address} + } var err error // Go's built-in resolver dials a fresh connection per query exchange (escalating // from UDP to TCP for truncated responses), so each Dial maps to a DNS request. We - // record metrics at this connection level; richer rcode-level metrics would require - // parsing the DNS response off the returned conn. - for _, server := range servers { + // record metrics at this connection level. + for _, server := range dialServers { recordDNSMetric(ctx, metricsDNSRequestsTotal, map[string]string{"server": server}) var conn net.Conn if conn, err = d.DialContext(ctx, network, server); err == nil { diff --git a/pkg/ffnet/config.go b/pkg/ffnet/config.go index 35213a2..e307c7a 100644 --- a/pkg/ffnet/config.go +++ b/pkg/ffnet/config.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -26,32 +26,25 @@ import ( const ( // CIDRDenylist is the list of CIDR ranges to which outbound connections are blocked, as a - // core SSRF mitigation. It is empty by default — ffnet/ffresty is frequently used for private - // service-to-service traffic, so we do not presume which ranges are off-limits. Callers should - // compose an appropriate denylist from the exported building-block lists below (e.g. - // RecommendedSSRFDenylist for externally-reachable/webhook clients, or a narrower set such as - // CloudMetadataCIDRs for internal clients that still want to block IMDS). - CIDRDenylist = "cidrDenylist" + // core SSRF mitigation. It is empty by default. Callers should + // compose an appropriate denylist depending on the client's use case. + NetCIDRDenylist = "cidrDenylist" ) -// Config is the combined outbound-dialer configuration: the DNS resolver settings plus the -// egress CIDR denylist. +// Config is the outbound-dialer configuration. type Config struct { // CIDRDenylist is the set of CIDR ranges to block outbound connections to. Empty means no - // restriction. Compose it from the exported building-block lists — e.g. - // SSRFDenylist for any externally-configurable/webhook dialer. + // restriction. CIDRDenylist []string } func InitConfig(conf config.Section) { ffdns.InitConfig(conf) - conf.AddKnownKey(CIDRDenylist) + conf.AddKnownKey(NetCIDRDenylist) } func GenerateConfig(conf config.Section) (*Config, error) { cfg := &Config{} - // Empty by default (no egress restriction); callers opt in via config or by composing one of - // the exported denylists. - cfg.CIDRDenylist = conf.GetStringSlice(CIDRDenylist) + cfg.CIDRDenylist = conf.GetStringSlice(NetCIDRDenylist) return cfg, nil } diff --git a/pkg/ffnet/ffnet.go b/pkg/ffnet/ffnet.go index 93446b1..0a30f92 100644 --- a/pkg/ffnet/ffnet.go +++ b/pkg/ffnet/ffnet.go @@ -24,10 +24,9 @@ import ( "github.com/hyperledger/firefly-common/pkg/i18n" ) -// NewDialer builds a *net.Dialer wired with the custom DNS resolver (if any) and the SSRF -// egress guard (on by default). The caller is responsible for setting Timeout / KeepAlive to -// suit its protocol. Exported so any dialer-based client — HTTP, WebSocket, etc. — can apply -// identical outbound protection from the same config. +// NewDialer builds a *net.Dialer wired with the CIDR egress guard and provided the DNS resolver (if any). +// The caller is responsible for setting Timeout / KeepAlive to suit its protocol. Exported so any dialer-based +// client — HTTP, WebSocket, etc. — can apply identical outbound protection from the same config. func NewDialer(ctx context.Context, cfg *Config, resolver *net.Resolver) (*net.Dialer, error) { control, err := NewDialControl(ctx, cfg) if err != nil { @@ -42,7 +41,7 @@ func NewDialer(ctx context.Context, cfg *Config, resolver *net.Resolver) (*net.D type DialControl func(network, address string, c syscall.RawConn) error // NewDialControl builds a net.Dialer Control function that rejects connections to any address -// inside the effective CIDR denylist — the core SSRF mitigation. It runs after DNS resolution +// inside the effective CIDR denylist. It runs after DNS resolution // against the actual resolved IP, so it also defeats DNS-rebinding and literal-IP bypasses. // Returns (nil, nil) when the effective denylist is empty (no restrictions). func NewDialControl(ctx context.Context, cfg *Config) (DialControl, error) { diff --git a/pkg/ffnet/ffnet_test.go b/pkg/ffnet/ffnet_test.go index 77aedda..08af0e2 100644 --- a/pkg/ffnet/ffnet_test.go +++ b/pkg/ffnet/ffnet_test.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -182,7 +182,7 @@ func TestGenerateConfigDenylistFromConfig(t *testing.T) { "172.16.0.0/12", "192.168.0.0/16", } - utConf.Set(CIDRDenylist, ipv4PrivateCIDRs) + utConf.Set(NetCIDRDenylist, ipv4PrivateCIDRs) cfg, err := GenerateConfig(utConf) require.NoError(t, err) assert.Equal(t, ipv4PrivateCIDRs, cfg.CIDRDenylist) @@ -227,7 +227,7 @@ func TestGenerateConfigDenylistSemantics(t *testing.T) { // Configured denylist => guard active resetConf() - utConf.Set(CIDRDenylist, testSSRDenylist) + utConf.Set(NetCIDRDenylist, testSSRDenylist) cfg, err = GenerateConfig(utConf) require.NoError(t, err) assert.NotEmpty(t, cfg.CIDRDenylist) diff --git a/pkg/ffresty/ffresty_test.go b/pkg/ffresty/ffresty_test.go index ff0382a..d60de28 100644 --- a/pkg/ffresty/ffresty_test.go +++ b/pkg/ffresty/ffresty_test.go @@ -841,7 +841,7 @@ func TestDialControlBlocksLoopbackWhenConfigured(t *testing.T) { ssrfDenylist := []string{ "127.0.0.0/8", } - utConf.SubSection("net").Set(ffnet.CIDRDenylist, ssrfDenylist) + utConf.SubSection("net").Set(ffnet.NetCIDRDenylist, ssrfDenylist) utConf.Set(HTTPConfigURL, "http://127.0.0.1:1") c, err := New(context.Background(), utConf) require.NoError(t, err) diff --git a/pkg/wsclient/wsconfig_test.go b/pkg/wsclient/wsconfig_test.go index 064cf82..e49986a 100644 --- a/pkg/wsclient/wsconfig_test.go +++ b/pkg/wsclient/wsconfig_test.go @@ -93,7 +93,7 @@ func TestWSConfigNetDialerCustom(t *testing.T) { "240.0.0.0/4", } utConf.SubSection("dns").Set(ffdns.DNSServers, []string{"8.8.8.8"}) - utConf.SubSection("net").Set(ffnet.CIDRDenylist, ssrfDenylist) // opt in to the egress guard + utConf.SubSection("net").Set(ffnet.NetCIDRDenylist, ssrfDenylist) // opt in to the egress guard ctx := context.Background() wsConfig, err := GenerateConfig(ctx, utConf) From edffad134caf032bcfc47df1bbdd33bef1cc6577 Mon Sep 17 00:00:00 2001 From: hfuss Date: Sun, 21 Jun 2026 14:47:53 -0400 Subject: [PATCH 09/10] fix config for ffnet Signed-off-by: hfuss --- pkg/ffnet/config.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/ffnet/config.go b/pkg/ffnet/config.go index e307c7a..92844b5 100644 --- a/pkg/ffnet/config.go +++ b/pkg/ffnet/config.go @@ -21,7 +21,6 @@ package ffnet import ( "github.com/hyperledger/firefly-common/pkg/config" - "github.com/hyperledger/firefly-common/pkg/ffdns" ) const ( @@ -39,7 +38,6 @@ type Config struct { } func InitConfig(conf config.Section) { - ffdns.InitConfig(conf) conf.AddKnownKey(NetCIDRDenylist) } From b0364c7a949683a6a1f6cf8eed0131fa2f0b1e50 Mon Sep 17 00:00:00 2001 From: hfuss Date: Tue, 23 Jun 2026 14:45:17 -0400 Subject: [PATCH 10/10] fix copyright Signed-off-by: hfuss --- pkg/ffnet/ffnet.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ffnet/ffnet.go b/pkg/ffnet/ffnet.go index 0a30f92..b3f4ae1 100644 --- a/pkg/ffnet/ffnet.go +++ b/pkg/ffnet/ffnet.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 //