Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions internal/cmd/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Config struct {
HttpsPort int
MetricsPort int
HTTP3Enabled bool
TLSCipherSuites string

AlternateConfigDir string
}
Expand Down
11 changes: 11 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
},
}

Expand Down
58 changes: 58 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
40 changes: 40 additions & 0 deletions internal/server/tls_config.go
Original file line number Diff line number Diff line change
@@ -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
}
80 changes: 80 additions & 0 deletions internal/server/tls_config_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
})
}