From 6486760c3affceb6c1f10f281bf92839762cf6d1 Mon Sep 17 00:00:00 2001 From: Constantin De La Roche Date: Thu, 4 Jun 2026 23:58:36 +0200 Subject: [PATCH 1/2] Add HTTP Basic Auth support to deploy Add a --basic-auth flag to `kamal-proxy deploy` that protects a service behind HTTP Basic Authentication. Requests without valid credentials receive a 401 with a WWW-Authenticate challenge. The password is hashed (SHA-256) before it is stored in the service options, so the proxy never persists it in plaintext, and credentials are compared in constant time to avoid timing attacks. The middleware runs inside the error-page middleware so a custom 401 page can render. Co-Authored-By: Claude Opus 4.8 --- README.md | 13 ++++ internal/cmd/deploy.go | 13 ++++ internal/server/basic_auth_middleware.go | 67 +++++++++++++++++++ internal/server/basic_auth_middleware_test.go | 58 ++++++++++++++++ internal/server/service.go | 7 ++ 5 files changed, 158 insertions(+) create mode 100644 internal/server/basic_auth_middleware.go create mode 100644 internal/server/basic_auth_middleware_test.go diff --git a/README.md b/README.md index 03d34de3..f81b2df9 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 +### Basic authentication + +You can require HTTP Basic Authentication for a service by passing the +`--basic-auth` flag with a `username:password` pair when deploying: + + kamal-proxy deploy service1 --target web-1:3000 --basic-auth admin:secret + +Requests without valid credentials receive a `401 Unauthorized` response with a +`WWW-Authenticate` challenge. The password is hashed before it is stored, and +credentials are compared in constant time. Because credentials are sent on every +request, enable TLS when using basic auth. + + ## Specifying `run` options with environment variables In some environments, like when running a Docker container, it can be convenient diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index 01010c89..69fd654e 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -4,6 +4,7 @@ import ( "fmt" "net/rpc" "slices" + "strings" "github.com/spf13/cobra" @@ -14,6 +15,7 @@ type deployCommand struct { cmd *cobra.Command args server.DeployArgs tlsStaging bool + basicAuth string } func newDeployCommand() *deployCommand { @@ -66,6 +68,8 @@ func newDeployCommand() *deployCommand { deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.ForwardHeaders, "forward-headers", false, "Forward X-Forwarded headers to target (default false if TLS enabled; otherwise true)") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.ScopeCookiePaths, "scope-cookie-paths", false, "Scope cookie paths to match path prefix") + deployCommand.cmd.Flags().StringVar(&deployCommand.basicAuth, "basic-auth", "", "Require HTTP Basic Auth, in the form username:password") + deployCommand.cmd.MarkFlagRequired("target") deployCommand.cmd.MarkFlagsRequiredTogether("tls-certificate-path", "tls-private-key-path") @@ -88,6 +92,15 @@ func (c *deployCommand) run(cmd *cobra.Command, args []string) error { func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error { c.args.ServiceOptions.Normalize() + if c.basicAuth != "" { + username, password, found := strings.Cut(c.basicAuth, ":") + if !found || username == "" || password == "" { + return fmt.Errorf("basic-auth must be in the form username:password") + } + c.args.ServiceOptions.BasicAuthUsername = username + c.args.ServiceOptions.BasicAuthPasswordHash = server.HashBasicAuthCredential(password) + } + if cmd.Flags().Changed("max-request-body") && !cmd.Flags().Changed("buffer-requests") { return fmt.Errorf("max-request-body can only be set when request buffering is enabled") } diff --git a/internal/server/basic_auth_middleware.go b/internal/server/basic_auth_middleware.go new file mode 100644 index 00000000..dc47f154 --- /dev/null +++ b/internal/server/basic_auth_middleware.go @@ -0,0 +1,67 @@ +package server + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/http" +) + +const basicAuthRealm = `Basic realm="Restricted", charset="UTF-8"` + +// HashBasicAuthCredential returns the hex-encoded SHA-256 digest of a Basic Auth +// credential. The password is hashed before it is stored in the service options, +// so the proxy never persists it in plaintext. +func HashBasicAuthCredential(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +type BasicAuthMiddleware struct { + username string + passwordHash []byte + next http.Handler +} + +func WithBasicAuthMiddleware(username, passwordHash string, next http.Handler) http.Handler { + decoded, err := hex.DecodeString(passwordHash) + if err != nil { + decoded = nil + } + + return &BasicAuthMiddleware{ + username: username, + passwordHash: decoded, + next: next, + } +} + +func (h *BasicAuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h.authenticated(r) { + h.next.ServeHTTP(w, r) + return + } + + w.Header().Set("WWW-Authenticate", basicAuthRealm) + SetErrorResponse(w, r, http.StatusUnauthorized, nil) +} + +// Private + +func (h *BasicAuthMiddleware) authenticated(r *http.Request) bool { + username, password, ok := r.BasicAuth() + if !ok { + return false + } + + // Hash both sides so the comparisons run in constant time over equal-length + // inputs, regardless of the supplied credential lengths. + expectedUser := sha256.Sum256([]byte(h.username)) + givenUser := sha256.Sum256([]byte(username)) + givenPassword := sha256.Sum256([]byte(password)) + + userMatch := subtle.ConstantTimeCompare(givenUser[:], expectedUser[:]) == 1 + passwordMatch := subtle.ConstantTimeCompare(givenPassword[:], h.passwordHash) == 1 + + return userMatch && passwordMatch +} diff --git a/internal/server/basic_auth_middleware_test.go b/internal/server/basic_auth_middleware_test.go new file mode 100644 index 00000000..89b5d9f7 --- /dev/null +++ b/internal/server/basic_auth_middleware_test.go @@ -0,0 +1,58 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBasicAuthMiddleware(t *testing.T) { + reached := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + }) + handler := WithBasicAuthMiddleware("admin", HashBasicAuthCredential("secret"), next) + + send := func(setAuth func(*http.Request)) *httptest.ResponseRecorder { + reached = false + r := httptest.NewRequest("GET", "/", nil) + if setAuth != nil { + setAuth(r) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + + t.Run("allows requests with correct credentials", func(t *testing.T) { + w := send(func(r *http.Request) { r.SetBasicAuth("admin", "secret") }) + + assert.True(t, reached) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("rejects requests with no credentials", func(t *testing.T) { + w := send(nil) + + assert.False(t, reached) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, basicAuthRealm, w.Header().Get("WWW-Authenticate")) + }) + + t.Run("rejects requests with a wrong password", func(t *testing.T) { + w := send(func(r *http.Request) { r.SetBasicAuth("admin", "wrong") }) + + assert.False(t, reached) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("rejects requests with a wrong username", func(t *testing.T) { + w := send(func(r *http.Request) { r.SetBasicAuth("root", "secret") }) + + assert.False(t, reached) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) +} diff --git a/internal/server/service.go b/internal/server/service.go index 56953137..1e210acb 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -91,6 +91,8 @@ type ServiceOptions struct { StripPrefix bool `json:"strip_prefix"` WriterAffinityTimeout time.Duration `json:"writer_affinity_timeout"` ReadTargetsAcceptWebsockets bool `json:"read_targets_accept_websockets"` + BasicAuthUsername string `json:"basic_auth_username"` + BasicAuthPasswordHash string `json:"basic_auth_password_hash"` } func (so *ServiceOptions) Normalize() { @@ -404,6 +406,11 @@ func (s *Service) createMiddleware(options ServiceOptions, certManager CertManag var err error var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget) + if options.BasicAuthUsername != "" { + slog.Debug("Using basic auth", "service", s.name) + handler = WithBasicAuthMiddleware(options.BasicAuthUsername, options.BasicAuthPasswordHash, handler) + } + if options.ErrorPagePath != "" { slog.Debug("Using custom error pages", "service", s.name, "path", options.ErrorPagePath) errorPageFS := os.DirFS(options.ErrorPagePath) From 97f11fe7ddc907e3337cc9bbc21c0fad996eef8e Mon Sep 17 00:00:00 2001 From: Constantin De La Roche Date: Fri, 5 Jun 2026 00:21:45 +0200 Subject: [PATCH 2/2] Harden basic auth based on review feedback - Only enable basic auth when both username and password hash are set, so a partially-configured service isn't silently locked out (BasicAuthEnabled). - Fail closed on a malformed/wrong-length stored hash by falling back to a fixed-length zero hash, keeping comparisons constant-time over equal lengths. - Precompute the username hash once at construction instead of per request. - Document that command-line credentials can leak via shell history, process listings, and CI logs. - Cover the invalid-hash cases with tests. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +++ internal/server/basic_auth_middleware.go | 14 ++++++----- internal/server/basic_auth_middleware_test.go | 25 +++++++++++++++++++ internal/server/service.go | 9 ++++++- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f81b2df9..3d8b00f1 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,10 @@ Requests without valid credentials receive a `401 Unauthorized` response with a credentials are compared in constant time. Because credentials are sent on every request, enable TLS when using basic auth. +> **Note:** the credentials are passed on the command line, so they may be +> visible in shell history, process listings (`ps`), and CI logs. Treat them as +> a secret and supply them from your secret store rather than hard-coding them. + ## Specifying `run` options with environment variables diff --git a/internal/server/basic_auth_middleware.go b/internal/server/basic_auth_middleware.go index dc47f154..6c70ad21 100644 --- a/internal/server/basic_auth_middleware.go +++ b/internal/server/basic_auth_middleware.go @@ -18,19 +18,22 @@ func HashBasicAuthCredential(value string) string { } type BasicAuthMiddleware struct { - username string + usernameHash [sha256.Size]byte passwordHash []byte next http.Handler } func WithBasicAuthMiddleware(username, passwordHash string, next http.Handler) http.Handler { + // If the stored hash is malformed (bad hex or not a SHA-256 digest), fall back + // to a fixed-length zero hash. No real password hashes to all zeroes, so every + // request fails closed while comparisons still run over equal-length inputs. decoded, err := hex.DecodeString(passwordHash) - if err != nil { - decoded = nil + if err != nil || len(decoded) != sha256.Size { + decoded = make([]byte, sha256.Size) } return &BasicAuthMiddleware{ - username: username, + usernameHash: sha256.Sum256([]byte(username)), passwordHash: decoded, next: next, } @@ -56,11 +59,10 @@ func (h *BasicAuthMiddleware) authenticated(r *http.Request) bool { // Hash both sides so the comparisons run in constant time over equal-length // inputs, regardless of the supplied credential lengths. - expectedUser := sha256.Sum256([]byte(h.username)) givenUser := sha256.Sum256([]byte(username)) givenPassword := sha256.Sum256([]byte(password)) - userMatch := subtle.ConstantTimeCompare(givenUser[:], expectedUser[:]) == 1 + userMatch := subtle.ConstantTimeCompare(givenUser[:], h.usernameHash[:]) == 1 passwordMatch := subtle.ConstantTimeCompare(givenPassword[:], h.passwordHash) == 1 return userMatch && passwordMatch diff --git a/internal/server/basic_auth_middleware_test.go b/internal/server/basic_auth_middleware_test.go index 89b5d9f7..55a11085 100644 --- a/internal/server/basic_auth_middleware_test.go +++ b/internal/server/basic_auth_middleware_test.go @@ -56,3 +56,28 @@ func TestBasicAuthMiddleware(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) }) } + +func TestBasicAuthMiddleware_FailsClosedWithInvalidHash(t *testing.T) { + for name, passwordHash := range map[string]string{ + "non-hex hash": "not-a-valid-hash", + "wrong-length hash": "abcd", + "empty hash": "", + } { + t.Run(name, func(t *testing.T) { + reached := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + }) + handler := WithBasicAuthMiddleware("admin", passwordHash, next) + + r := httptest.NewRequest("GET", "/", nil) + r.SetBasicAuth("admin", "secret") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + assert.False(t, reached, "request must not reach the backend") + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, basicAuthRealm, w.Header().Get("WWW-Authenticate")) + }) + } +} diff --git a/internal/server/service.go b/internal/server/service.go index 1e210acb..c2c50ffd 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -100,6 +100,13 @@ func (so *ServiceOptions) Normalize() { so.PathPrefixes = NormalizePathPrefixes(so.PathPrefixes) } +// BasicAuthEnabled reports whether Basic Auth should be enforced. Both the +// username and the password hash must be set; otherwise we leave it disabled +// rather than installing a middleware that can never authenticate. +func (so *ServiceOptions) BasicAuthEnabled() bool { + return so.BasicAuthUsername != "" && so.BasicAuthPasswordHash != "" +} + func (so *ServiceOptions) WithHosts(hosts []string) ServiceOptions { options := *so options.Hosts = hosts @@ -406,7 +413,7 @@ func (s *Service) createMiddleware(options ServiceOptions, certManager CertManag var err error var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget) - if options.BasicAuthUsername != "" { + if options.BasicAuthEnabled() { slog.Debug("Using basic auth", "service", s.name) handler = WithBasicAuthMiddleware(options.BasicAuthUsername, options.BasicAuthPasswordHash, handler) }