Skip to content
Merged
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,54 @@ stripped path prefixes should specify their excluded paths in the un-prefixed
form.


### Password-protecting a service

To put a service behind an HTTP Basic password prompt, deploy it with
`--basic-auth`:

kamal-proxy deploy service1 --target web-1:3000 --tls --host app.example.com --basic-auth admin:s3cr3t

Requests without valid credentials get a `401` and a browser password prompt.
The password is hashed by the CLI before it is sent to the proxy, so neither
the RPC socket nor the saved state file ever sees the plaintext.

Things worth knowing:

* **Use it with TLS.** Basic credentials are replayable and are re-sent on
every request. On a service deployed with `--tls`, plaintext requests are
redirected to HTTPS *before* any challenge is issued, so the password is
never solicited in the clear. If you turn that redirect off with
`--tls-redirect=false`, or deploy without `--tls` at all, the proxy logs a
warning and challenges over plaintext — only do that when TLS is terminated
in front of the proxy.
* **The health check path stays open.** `GET` and `HEAD` on the configured
`--health-check-path` are served without credentials, so downstream load
balancers can still see the service drain during a deploy. Deploying with
both `--basic-auth` and a health check path of `/` is rejected, since that
would leave the service's index page public.
* **The credential is removed before forwarding.** Your application never sees
the proxy's `Authorization` header, so it cannot be logged by
`--log-request-header authorization` or read by the upstream.
* **Rollout targets inherit it.** `kamal-proxy rollout deploy` reuses the
service's stored options, so rollout traffic stays protected.
* **Redeploying without the flag removes protection.** The credential is not
sticky; a deploy that omits `--basic-auth` leaves the service open.
* **Rolling the proxy image back removes protection silently.** A binary older
than this feature ignores the stored credential and the next state save drops
it. Redeploy with `--basic-auth` after any proxy rollback.
* **The password reaches the deploy host's process table.** It is an ordinary
command-line argument, so it is visible to `ps` and to anything that logs the
command.
* **A per-path prefix is routing, not a security boundary.** You can protect
part of a site by deploying it as its own `--path-prefix` service with its own
credential, but prefix matching does not normalize paths — give the protected
prefix a target that does not also serve the same content under an
unprotected root.

If you use `--error-pages`, add a `401.html` to that directory; otherwise the
challenge falls back to the proxy's built-in plain response.


### Automatic TLS

Kamal Proxy can automatically obtain and renew TLS certificates for your
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Proxy-side roadmap for the dash fork. The cross-repo release sequencing, strateg

| Item | Evidence | Anchor |
|---|---|---|
| Basic auth per service/path | port PR #216 (open); kamal#1604 | new `ServiceOptions` field + middleware in `createMiddleware` (`service.go:458`) |
| Basic auth per service | port PR #216 (open); kamal#1604 | DONE — `--basic-auth <user>:<pass>` (`internal/server/basic_auth.go`). Deliberately **not** a `createMiddleware` middleware: that chain wraps `serviceRequestWithTarget`, which is where the HTTPS redirect lives, so a middleware there challenges before the 301 and the browser sends the password in cleartext. The check sits inline after `handleRedirectsIfNeeded`. Credentials are hashed CLI-side (salted SHA-256), so no plaintext crosses the RPC socket or reaches the state file. Per-path scoping is served by deploying a `--path-prefix` service with its own credential |
| IP allow/deny (CIDR) | discussions #143/#144 | middleware; client addr extraction exists (`logging_middleware.go:70`) |
| Per-IP rate limiting (token bucket + burst + allowlist) | rejected #20 | global chain (`server.go:211 buildHandler`) or per-service; `golang.org/x/time/rate` |
| PROXY protocol | rejected #31, discussion #41 | `go-proxyproto` listener wrap in `server.go`; `run` flag |
Expand Down
32 changes: 32 additions & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"net/rpc"
"strings"
"time"

"github.com/spf13/cobra"
Expand All @@ -16,6 +17,7 @@ type deployCommand struct {
tlsStaging bool
pathTimeouts map[string]string
pathRequestTimeouts map[string]string
basicAuth string
}

func newDeployCommand() *deployCommand {
Expand Down Expand Up @@ -79,6 +81,7 @@ func newDeployCommand() *deployCommand {
deployCommand.cmd.Flags().Int64Var(&deployCommand.args.TargetOptions.MaxResponseBodySize, "max-response-body", server.DefaultMaxResponseBodySize, "Max size of response body when buffering (default of 0 means unlimited)")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.ErrorPagePath, "error-pages", "", "Path to custom error pages")
deployCommand.cmd.Flags().IntSliceVar(&deployCommand.args.ServiceOptions.InterceptErrorStatuses, "intercept-errors", nil, "Replace these response statuses from the target with the proxy's error pages, as 4xx or 5xx codes (e.g. 502,503,504; default none)")
deployCommand.cmd.Flags().StringVar(&deployCommand.basicAuth, "basic-auth", "", "Require HTTP Basic credentials on every request to this service, as <username>:<password>. The health check path stays open. Use with --tls, or terminate TLS in front of the proxy -- Basic credentials are replayable and are sent on every request")

deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogRequestHeaders, "log-request-header", nil, "Additional request header to log (may be specified multiple times)")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogResponseHeaders, "log-response-header", nil, "Additional response header to log (may be specified multiple times)")
Expand Down Expand Up @@ -130,6 +133,20 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error {
return err
}

if c.basicAuth != "" {
username, password, err := parseBasicAuthFlag(c.basicAuth)
if err != nil {
return err
}

// Hash here, so the plaintext credential never crosses the RPC socket
// and never reaches the state file.
c.args.ServiceOptions.BasicAuth, err = server.EncodeBasicAuthCredential(username, password)
if err != nil {
return err
}
}

if err := c.args.TargetOptions.Validate(); err != nil {
return err
}
Expand All @@ -141,6 +158,21 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error {
return nil
}

// parseBasicAuthFlag splits a <username>:<password> flag value. It cuts at the
// first colon, matching how net/http decodes the credentials a client sends:
// passwords may contain colons, usernames may not.
func parseBasicAuthFlag(value string) (string, string, error) {
username, password, found := strings.Cut(value, ":")
if !found {
return "", "", fmt.Errorf("%w: basic-auth must be given as <username>:<password>", server.ErrServiceOptionsInvalid)
}
if username == "" || password == "" {
return "", "", fmt.Errorf("%w: basic-auth needs both a username and a password", server.ErrServiceOptionsInvalid)
}

return username, password, nil
}

// parsePathTimeouts converts the <prefix>=<duration> flag pairs into the
// server's normalized, longest-prefix-first form. The map's iteration order is
// random, so normalizing here is what makes the deployed order deterministic.
Expand Down
80 changes: 80 additions & 0 deletions internal/cmd/deploy_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"strings"
"testing"
"time"

Expand Down Expand Up @@ -388,3 +389,82 @@ func TestDeployCommand_TargetPoolFlags(t *testing.T) {
})
}
}

func TestParseBasicAuthFlag(t *testing.T) {
tests := []struct {
name string
value string
expectedUsername string
expectedPassword string
expectError bool
}{
{
name: "a simple credential",
value: "admin:s3cr3t",
expectedUsername: "admin",
expectedPassword: "s3cr3t",
},
{
// Passwords may contain colons; usernames may not. Splitting anywhere
// but the first colon corrupts the password.
name: "splits on the first colon only",
value: "admin:pa:ss:word",
expectedUsername: "admin",
expectedPassword: "pa:ss:word",
},
{name: "no colon", value: "adminpass", expectError: true},
{name: "empty username", value: ":pass", expectError: true},
{name: "empty password", value: "admin:", expectError: true},
{name: "colon only", value: ":", expectError: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
username, password, err := parseBasicAuthFlag(tt.value)

if tt.expectError {
require.Error(t, err)
return
}

require.NoError(t, err)
assert.Equal(t, tt.expectedUsername, username)
assert.Equal(t, tt.expectedPassword, password)
})
}
}

func TestDeployCommand_BasicAuthEncodesCredential(t *testing.T) {
cmd := newDeployCommand()
require.NoError(t, cmd.cmd.Flags().Parse([]string{"--target=web:3000", "--basic-auth=admin:s3cr3t"}))
require.NoError(t, cmd.preRun(cmd.cmd, []string{"test-service"}))

encoded := cmd.args.ServiceOptions.BasicAuth
require.NotEmpty(t, encoded)

// What crosses the RPC socket must be a hash, never the credential.
assert.NotContains(t, encoded, "admin")
assert.NotContains(t, encoded, "s3cr3t")
assert.True(t, strings.HasPrefix(encoded, "sha256:"))

// And it must be something the server can read back.
require.NoError(t, server.ServiceOptions{BasicAuth: encoded}.Validate())
}

func TestDeployCommand_BasicAuthRejectsMalformedValues(t *testing.T) {
cmd := newDeployCommand()
require.NoError(t, cmd.cmd.Flags().Parse([]string{"--target=web:3000", "--basic-auth=adminpass"}))

err := cmd.preRun(cmd.cmd, []string{"test-service"})

require.ErrorIs(t, err, server.ErrServiceOptionsInvalid)
require.ErrorContains(t, err, "basic-auth")
}

func TestDeployCommand_BasicAuthAbsentLeavesServiceUnprotected(t *testing.T) {
cmd := newDeployCommand()
require.NoError(t, cmd.cmd.Flags().Parse([]string{"--target=web:3000"}))

require.NoError(t, cmd.preRun(cmd.cmd, []string{"test-service"}))
assert.Empty(t, cmd.args.ServiceOptions.BasicAuth)
}
Loading
Loading