diff --git a/README.md b/README.md index 03d34de..8b35f38 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,19 @@ your certificate file and the corresponding private key: kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --tls --tls-certificate-path cert.pem --tls-private-key-path key.pem +### Limiting TLS cipher suites + +By default, Kamal Proxy uses Go's default TLS cipher suite selection, which includes some CBC-based ciphers that may be flagged as obsolete by security scanners. You can limit which cipher suites are enabled by specifying the `--tls-cipher-suites` flag when running the proxy: + + kamal-proxy run --tls-cipher-suites "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" + +Or using an environment variable: + + TLS_CIPHER_SUITES="TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" kamal-proxy run + +**Note:** When specifying cipher suites, make sure to use cipher suites that match your certificate type (RSA or ECDSA). TLS 1.3 cipher suites are always enabled and cannot be disabled. + + ## Specifying `run` options with environment variables In some environments, like when running a Docker container, it can be convenient diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 0a70655..354ebbe 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -29,6 +29,7 @@ func newRunCommand() *runCommand { runCommand.cmd.Flags().IntVar(&globalConfig.HttpsPort, "https-port", getEnvInt("HTTPS_PORT", server.DefaultHttpsPort), "Port to serve HTTPS traffic on") runCommand.cmd.Flags().IntVar(&globalConfig.MetricsPort, "metrics-port", getEnvInt("METRICS_PORT", 0), "Publish metrics on the specified port (default zero to disable)") runCommand.cmd.Flags().BoolVar(&globalConfig.HTTP3Enabled, "http3", false, "Enable HTTP/3") + runCommand.cmd.Flags().StringVar(&globalConfig.TLSCipherSuites, "tls-cipher-suites", getEnvString("TLS_CIPHER_SUITES", ""), "Comma-separated list of TLS cipher suite names to enable (empty for default)") return runCommand } diff --git a/internal/cmd/util.go b/internal/cmd/util.go index d28618c..b1d1e30 100644 --- a/internal/cmd/util.go +++ b/internal/cmd/util.go @@ -60,3 +60,12 @@ func getEnvBool(key string, defaultValue bool) bool { return boolValue } + +func getEnvString(key string, defaultValue string) string { + value, ok := findEnv(key) + if !ok { + return defaultValue + } + + return value +} diff --git a/internal/server/config.go b/internal/server/config.go index 0314825..7da8edb 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -18,6 +18,7 @@ type Config struct { HttpsPort int MetricsPort int HTTP3Enabled bool + TLSCipherSuites string AlternateConfigDir string } diff --git a/internal/server/server.go b/internal/server/server.go index 4b336de..b8cb8d7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -150,6 +150,16 @@ func (s *Server) startHTTPServers() error { return err } s.httpsListener = httpsListener + + // Parse cipher suites if configured + var cipherSuites []uint16 + if s.config.TLSCipherSuites != "" { + cipherSuites, err = ParseCipherSuites(s.config.TLSCipherSuites) + if err != nil { + return fmt.Errorf("invalid cipher suites configuration: %w", err) + } + } + s.httpsServer = &http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.config.HTTP3Enabled { @@ -161,6 +171,7 @@ func (s *Server) startHTTPServers() error { TLSConfig: &tls.Config{ NextProtos: []string{"h2", "http/1.1", acme.ALPNProto}, GetCertificate: s.router.GetCertificate, + CipherSuites: cipherSuites, }, } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4792d3d..3c314d4 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -162,3 +162,61 @@ func testRequestUsingTransport(server *Server, transport http.RoundTripper) (*ht uri := fmt.Sprintf("https://localhost:%d/", server.HttpsPort()) return client.Get(uri) } + +func TestServer_TLSCipherSuites(t *testing.T) { + t.Run("with custom cipher suites", func(t *testing.T) { + target := testTarget(t, func(w http.ResponseWriter, r *http.Request) {}) + + // Create server with custom cipher suites (only non-CBC suites) + // Note: Using ECDSA cipher suites since the test certificate is ECDSA-based + config := &Config{ + HttpPort: 0, + HttpsPort: 0, + TLSCipherSuites: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + } + router := NewRouter(t.TempDir() + "/state") + server := NewServer(config, router) + err := server.Start() + require.NoError(t, err) + t.Cleanup(func() { server.Stop() }) + + certPath, keyPath := prepareTestCertificateFiles(t) + serviceOptions := defaultServiceOptions + serviceOptions.TLSEnabled = true + serviceOptions.TLSCertificatePath = certPath + serviceOptions.TLSPrivateKeyPath = keyPath + + testDeployTarget(t, target, server, serviceOptions) + + // Test that HTTPS works with the configured cipher suites + // Force TLS 1.2 to test cipher suite configuration (TLS 1.3 ciphers are not configurable) + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + }, + }, + } + + resp, err := testRequestUsingTransport(server, transport) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, uint16(tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), resp.TLS.CipherSuite) + }) + + t.Run("with invalid cipher suite configuration", func(t *testing.T) { + config := &Config{ + HttpPort: 0, + HttpsPort: 0, + TLSCipherSuites: "TLS_INVALID_CIPHER_SUITE", + } + router := NewRouter(t.TempDir() + "/state") + server := NewServer(config, router) + + err := server.Start() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown cipher suite") + }) +} diff --git a/internal/server/tls_config.go b/internal/server/tls_config.go new file mode 100644 index 0000000..567859b --- /dev/null +++ b/internal/server/tls_config.go @@ -0,0 +1,40 @@ +package server + +import ( + "crypto/tls" + "fmt" + "strings" +) + +// ParseCipherSuites converts a comma-separated list of cipher suite names to their IDs +func ParseCipherSuites(cipherSuitesStr string) ([]uint16, error) { + if cipherSuitesStr == "" { + return nil, nil + } + + // Build a map of cipher suite names to IDs + cipherSuiteMap := make(map[string]uint16) + for _, suite := range tls.CipherSuites() { + cipherSuiteMap[suite.Name] = suite.ID + } + for _, suite := range tls.InsecureCipherSuites() { + cipherSuiteMap[suite.Name] = suite.ID + } + + // Parse the comma-separated list + names := strings.Split(cipherSuitesStr, ",") + suites := make([]uint16, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + id, ok := cipherSuiteMap[name] + if !ok { + return nil, fmt.Errorf("unknown cipher suite: %s", name) + } + suites = append(suites, id) + } + + return suites, nil +} diff --git a/internal/server/tls_config_test.go b/internal/server/tls_config_test.go new file mode 100644 index 0000000..55fc658 --- /dev/null +++ b/internal/server/tls_config_test.go @@ -0,0 +1,80 @@ +package server + +import ( + "crypto/tls" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCipherSuites(t *testing.T) { + t.Run("empty string returns nil", func(t *testing.T) { + suites, err := ParseCipherSuites("") + require.NoError(t, err) + assert.Nil(t, suites) + }) + + t.Run("single cipher suite", func(t *testing.T) { + suites, err := ParseCipherSuites("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("multiple cipher suites", func(t *testing.T) { + suites, err := ParseCipherSuites("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") + require.NoError(t, err) + assert.Equal(t, []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, suites) + }) + + t.Run("cipher suites with spaces", func(t *testing.T) { + suites, err := ParseCipherSuites("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 , TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") + require.NoError(t, err) + assert.Equal(t, []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, suites) + }) + + t.Run("unknown cipher suite returns error", func(t *testing.T) { + _, err := ParseCipherSuites("TLS_UNKNOWN_CIPHER") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown cipher suite") + }) + + t.Run("TLS 1.3 cipher suites", func(t *testing.T) { + suites, err := ParseCipherSuites("TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256") + require.NoError(t, err) + assert.Equal(t, []uint16{ + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_AES_256_GCM_SHA384, + tls.TLS_CHACHA20_POLY1305_SHA256, + }, suites) + }) + + t.Run("modern cipher suites without CBC", func(t *testing.T) { + // These are the recommended modern cipher suites that don't use CBC + modernSuites := []string{ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", + } + + for _, suite := range modernSuites { + t.Run(suite, func(t *testing.T) { + suites, err := ParseCipherSuites(suite) + require.NoError(t, err) + assert.Len(t, suites, 1) + }) + } + }) +}