From 85aa679c751eebe8fc611ce3afdd3d2379fe63de Mon Sep 17 00:00:00 2001 From: 1seal Date: Mon, 2 Feb 2026 14:09:00 +0000 Subject: [PATCH] ct_server: gate plaintext Trillian gRPC behind explicit opt-in - add --trillian_insecure_backend to explicitly allow plaintext - keep plaintext default only for local backends (loopback/unix) - refuse non-local plaintext without TLS - add unit tests for the policy --- trillian/ctfe/ct_server/main.go | 97 +++++++++++++++++++++++++--- trillian/ctfe/ct_server/main_test.go | 60 +++++++++++++++++ 2 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 trillian/ctfe/ct_server/main_test.go diff --git a/trillian/ctfe/ct_server/main.go b/trillian/ctfe/ct_server/main.go index a61c3159d0..5709711522 100644 --- a/trillian/ctfe/ct_server/main.go +++ b/trillian/ctfe/ct_server/main.go @@ -24,7 +24,9 @@ import ( "crypto/tls" "flag" "fmt" + "net" "net/http" + "net/url" "os" "os/signal" "strconv" @@ -88,11 +90,20 @@ var ( cacheSize = flag.Int("cache_size", -1, "Size parameter set to 0 makes cache of unlimited size") cacheTTL = flag.Duration("cache_ttl", -1*time.Second, "Providing 0 TTL turns expiring off") trillianTLSCACertFile = flag.String("trillian_tls_ca_cert_file", "", "CA certificate file to use for secure connections with Trillian server") + trillianInsecureBackend = flag.Bool("trillian_insecure_backend", false, "Allow plaintext gRPC connections to Trillian backends (unsafe; prefer --trillian_tls_ca_cert_file). If unset, plaintext is only allowed for local backends (loopback/unix).") maxCertChainSize = flag.Int64("max_cert_chain_size", 512000, "Maximum size of certificate chain in bytes for add-chain and add-pre-chain endpoints (default: 512000 bytes = 500KB)") ) const unknownRemoteUser = "UNKNOWN_REMOTE" +type trillianTransportMode string + +const ( + trillianTransportTLS trillianTransportMode = "tls" + trillianTransportPlaintextLocal trillianTransportMode = "plaintext_local" + trillianTransportPlaintextFlag trillianTransportMode = "plaintext_flag" +) + // nolint:staticcheck func main() { klog.InitFlags(nil) @@ -145,15 +156,6 @@ func main() { } dialOpts := []grpc.DialOption{} - if *trillianTLSCACertFile != "" { - creds, err := credentials.NewClientTLSFromFile(*trillianTLSCACertFile, "") - if err != nil { - klog.Exitf("Failed to create TLS credentials from Trillian CA certificate: %v", err) - } - dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds)) - } else { - dialOpts = append(dialOpts, grpc.WithInsecure()) - } if len(*etcdServers) > 0 { // Use etcd to provide endpoint resolution. cfg := clientv3.Config{Endpoints: strings.Split(*etcdServers, ","), DialTimeout: 5 * time.Second} @@ -211,6 +213,28 @@ func main() { dialOpts = append(dialOpts, grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`)) } + backendSpecs := make([]string, 0, len(beMap)) + for _, be := range beMap { + backendSpecs = append(backendSpecs, be.BackendSpec) + } + securityOpt, mode, err := trillianBackendDialOption(*trillianTLSCACertFile, *trillianInsecureBackend, backendSpecs) + if err != nil { + klog.Exitf("%v", err) + } + if securityOpt != nil { + dialOpts = append(dialOpts, securityOpt) + } + switch mode { + case trillianTransportPlaintextLocal: + klog.Warning("Using plaintext gRPC for local Trillian backends (loopback/unix). This is unsafe across untrusted networks; configure --trillian_tls_ca_cert_file to use TLS.") + case trillianTransportPlaintextFlag: + klog.Warning("Using plaintext gRPC for Trillian backends because --trillian_insecure_backend is set. This is unsafe across untrusted networks; prefer --trillian_tls_ca_cert_file.") + case trillianTransportTLS: + // No extra log; TLS is the safe default. + default: + klog.Warningf("Unknown Trillian transport mode: %q", mode) + } + // Dial all our log backends. clientMap := make(map[string]trillian.TrillianLogClient) for _, be := range beMap { @@ -373,6 +397,61 @@ func main() { klog.Flush() } +func trillianBackendDialOption(trillianTLSCACertFile string, allowInsecure bool, backendSpecs []string) (grpc.DialOption, trillianTransportMode, error) { + if trillianTLSCACertFile != "" { + creds, err := credentials.NewClientTLSFromFile(trillianTLSCACertFile, "") + if err != nil { + return nil, "", fmt.Errorf("failed to create TLS credentials from Trillian CA certificate: %w", err) + } + return grpc.WithTransportCredentials(creds), trillianTransportTLS, nil + } + + if allowInsecure { + return grpc.WithInsecure(), trillianTransportPlaintextFlag, nil + } + + for _, backendSpec := range backendSpecs { + if !isLocalBackendSpec(backendSpec) { + return nil, "", fmt.Errorf("refusing to use plaintext gRPC to non-local Trillian backend %q without --trillian_tls_ca_cert_file (set --trillian_insecure_backend to override)", backendSpec) + } + } + return grpc.WithInsecure(), trillianTransportPlaintextLocal, nil +} + +func isLocalBackendSpec(backendSpec string) bool { + // Common local-only schemes. + if strings.HasPrefix(backendSpec, "unix:") || strings.HasPrefix(backendSpec, "unix://") { + return true + } + + addr := backendSpec + if strings.Contains(backendSpec, "://") { + if u, err := url.Parse(backendSpec); err == nil { + if u.Scheme == "unix" { + return true + } + if u.Host != "" { + addr = u.Host + } else if u.Path != "" { + addr = strings.TrimPrefix(u.Path, "/") + } + } + } + + host := addr + if h, _, err := net.SplitHostPort(addr); err == nil { + host = h + } + + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + // awaitSignal waits for standard termination signals, then runs the given // function; it should be run as a separate goroutine. func awaitSignal(doneFn func()) { diff --git a/trillian/ctfe/ct_server/main_test.go b/trillian/ctfe/ct_server/main_test.go new file mode 100644 index 0000000000..dd31d863d0 --- /dev/null +++ b/trillian/ctfe/ct_server/main_test.go @@ -0,0 +1,60 @@ +package main + +import "testing" + +func TestIsLocalBackendSpec(t *testing.T) { + tests := []struct { + name string + spec string + want bool + }{ + {name: "localhost", spec: "localhost:8090", want: true}, + {name: "ipv4_loopback", spec: "127.0.0.1:8090", want: true}, + {name: "ipv6_loopback", spec: "[::1]:8090", want: true}, + {name: "dns_scheme_localhost", spec: "dns:///localhost:8090", want: true}, + {name: "passthrough_scheme_loopback", spec: "passthrough:///127.0.0.1:8090", want: true}, + {name: "unix_scheme", spec: "unix:///tmp/trillian.sock", want: true}, + {name: "unix_prefix", spec: "unix:/tmp/trillian.sock", want: true}, + {name: "private_ipv4", spec: "10.0.0.1:8090", want: false}, + {name: "public_hostname", spec: "trillian.example:8090", want: false}, + {name: "passthrough_scheme_remote", spec: "passthrough:///10.0.0.1:8090", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isLocalBackendSpec(tt.spec); got != tt.want { + t.Fatalf("isLocalBackendSpec(%q)=%v, want %v", tt.spec, got, tt.want) + } + }) + } +} + +func TestTrillianBackendDialOption_PlaintextPolicy(t *testing.T) { + t.Run("reject_non_local_without_tls_or_flag", func(t *testing.T) { + _, _, err := trillianBackendDialOption("", false, []string{"10.0.0.1:8090"}) + if err == nil { + t.Fatalf("expected error, got nil") + } + }) + + t.Run("allow_local_without_tls_or_flag", func(t *testing.T) { + _, mode, err := trillianBackendDialOption("", false, []string{"localhost:8090"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mode != trillianTransportPlaintextLocal { + t.Fatalf("mode=%q, want %q", mode, trillianTransportPlaintextLocal) + } + }) + + t.Run("allow_flag_without_tls", func(t *testing.T) { + _, mode, err := trillianBackendDialOption("", true, []string{"10.0.0.1:8090"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mode != trillianTransportPlaintextFlag { + t.Fatalf("mode=%q, want %q", mode, trillianTransportPlaintextFlag) + } + }) +} +