diff --git a/README.md b/README.md index 03d34de3..3d8b00f1 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,23 @@ 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. + +> **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 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..6c70ad21 --- /dev/null +++ b/internal/server/basic_auth_middleware.go @@ -0,0 +1,69 @@ +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 { + 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 || len(decoded) != sha256.Size { + decoded = make([]byte, sha256.Size) + } + + return &BasicAuthMiddleware{ + usernameHash: sha256.Sum256([]byte(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. + givenUser := sha256.Sum256([]byte(username)) + givenPassword := sha256.Sum256([]byte(password)) + + 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 new file mode 100644 index 00000000..55a11085 --- /dev/null +++ b/internal/server/basic_auth_middleware_test.go @@ -0,0 +1,83 @@ +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) + }) +} + +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 56953137..c2c50ffd 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() { @@ -98,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 @@ -404,6 +413,11 @@ func (s *Service) createMiddleware(options ServiceOptions, certManager CertManag var err error var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget) + if options.BasicAuthEnabled() { + 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)