From 873c4e7358dbe603a88bc0eb52be6b2987beb45b Mon Sep 17 00:00:00 2001 From: Constantin De La Roche Date: Fri, 5 Jun 2026 00:08:17 +0200 Subject: [PATCH 1/2] Add basic auth support to the proxy Allow protecting an app behind HTTP Basic Auth from the deploy config: servers: web: proxy: basic_auth: username: "abc" password: "123456" This passes the credentials to kamal-proxy via a new --basic-auth flag, which enforces the auth and returns a 401 challenge for unauthenticated requests. Both username and password are required, and the option is inherited/overridable per role like the rest of the proxy config. Requires kamal-proxy with basic auth support (basecamp/kamal-proxy#216). The MINIMUM_VERSION bump is left until that ships in a release. Fixes #1604 Co-Authored-By: Claude Opus 4.8 --- lib/kamal/configuration/docs/proxy.yml | 14 +++++++++ lib/kamal/configuration/proxy.rb | 13 ++++++++- lib/kamal/configuration/validator/proxy.rb | 6 ++++ test/configuration/proxy_test.rb | 34 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/lib/kamal/configuration/docs/proxy.yml b/lib/kamal/configuration/docs/proxy.yml index 87e428e3c..824c43b2e 100644 --- a/lib/kamal/configuration/docs/proxy.yml +++ b/lib/kamal/configuration/docs/proxy.yml @@ -148,6 +148,20 @@ proxy: - X-Request-ID - X-Request-Start + # Basic authentication + # + # Protect the app behind HTTP Basic Auth, enforced by kamal-proxy. Requests + # without valid credentials receive a 401 response. + # + # Both `username` and `password` are required. Because credentials are sent on + # every request, enable `ssl` when using basic auth. + # + # Avoid committing the password in plain text - inject it from a secret instead: + # password: <%= ENV["WEB_PASSWORD"] %> + basic_auth: + username: "admin" + password: "secret" + # Run configuration # # These options are used when booting the proxy container. diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index 3f2d83fad..01cde6a18 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -67,6 +67,16 @@ def path_prefixes proxy_config["path_prefixes"] || proxy_config["path_prefix"]&.split(",") || [] end + def basic_auth? + proxy_config["basic_auth"].is_a?(Hash) + end + + def basic_auth + return nil unless basic_auth? + auth = proxy_config["basic_auth"] + "#{auth["username"]}:#{auth["password"]}" + end + def deploy_options { host: hosts, @@ -90,7 +100,8 @@ def deploy_options "tls-redirect": proxy_config.dig("ssl_redirect"), "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS, "log-response-header": proxy_config.dig("logging", "response_headers"), - "error-pages": error_pages + "error-pages": error_pages, + "basic-auth": basic_auth }.compact end diff --git a/lib/kamal/configuration/validator/proxy.rb b/lib/kamal/configuration/validator/proxy.rb index 5dc3fd46b..c7c1661d7 100644 --- a/lib/kamal/configuration/validator/proxy.rb +++ b/lib/kamal/configuration/validator/proxy.rb @@ -21,6 +21,12 @@ def validate! end end + if config["basic_auth"].is_a?(Hash) + if config["basic_auth"]["username"].blank? || config["basic_auth"]["password"].blank? + error "basic_auth requires both username and password to be set" + end + end + if run_config = config["run"] if run_config["bind_ips"].present? ensure_valid_bind_ips(config["bind_ips"]) diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index e0b328f3b..85ab786cb 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -105,6 +105,40 @@ class ConfigurationProxyTest < ActiveSupport::TestCase end end + test "basic auth in deploy options and command args" do + @deploy[:proxy] = { "basic_auth" => { "username" => "abc", "password" => "123456" } } + + proxy = config.proxy + assert_equal "abc:123456", proxy.deploy_options[:"basic-auth"] + assert_includes proxy.deploy_command_args(target: "172.1.0.2"), "--basic-auth=\"abc:123456\"" + end + + test "no basic auth option when not configured" do + @deploy[:proxy] = { "host" => "example.com" } + + proxy = config.proxy + assert_nil proxy.deploy_options[:"basic-auth"] + assert_not proxy.basic_auth? + assert_not_includes proxy.deploy_command_args(target: "172.1.0.2").join(" "), "--basic-auth" + end + + test "basic auth with only username" do + @deploy[:proxy] = { "basic_auth" => { "username" => "abc" } } + assert_raises(Kamal::ConfigurationError) { config.proxy } + end + + test "basic auth with only password" do + @deploy[:proxy] = { "basic_auth" => { "password" => "123456" } } + assert_raises(Kamal::ConfigurationError) { config.proxy } + end + + test "basic auth specialized on a role overrides root proxy config" do + @deploy[:proxy] = { "basic_auth" => { "username" => "abc", "password" => "123456" } } + @deploy[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "proxy" => { "basic_auth" => { "username" => "xyz", "password" => "secret" } } } } + + assert_equal "xyz:secret", config.role(:web).proxy.deploy_options[:"basic-auth"] + end + private def config Kamal::Configuration.new(@deploy) From 0d601a5683cb0e12728ed15b25201913ccbfbbe6 Mon Sep 17 00:00:00 2001 From: Constantin De La Roche Date: Fri, 5 Jun 2026 00:29:25 +0200 Subject: [PATCH 2/2] Redact basic auth credentials in proxy deploy command Wrap the --basic-auth argument as a sensitive value so the password is redacted in SSHKit command logs and other human-visible output, while still being passed verbatim to kamal-proxy. Also make the deploy-args test resilient to argument quoting and assert the credentials are redacted, plus cover that a non-Hash basic_auth is rejected (already enforced by the proxy schema). Co-Authored-By: Claude Opus 4.8 --- lib/kamal/configuration/proxy.rb | 15 ++++++++++++++- test/configuration/proxy_test.rb | 19 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index 01cde6a18..5edecc044 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -106,7 +106,13 @@ def deploy_options end def deploy_command_args(target:) - optionize ({ target: "#{target}:#{app_port}" }).merge(deploy_options), with: "=" + options = deploy_options + basic_auth = options.delete(:"basic-auth") + + [ + *optionize({ target: "#{target}:#{app_port}" }.merge(options), with: "="), + *basic_auth_args(basic_auth) + ] end def stop_options(drain_timeout: nil, message: nil) @@ -125,6 +131,13 @@ def merge(other) end private + # Wrap the basic auth credentials so they're redacted in command logs and + # other human-visible output, while still passed verbatim to kamal-proxy. + def basic_auth_args(value) + return [] if value.blank? + [ Kamal::Utils.sensitive("--basic-auth=#{Kamal::Utils.escape_shell_value(value)}", redaction: "--basic-auth=[REDACTED]") ] + end + def tls_path(directory, filename) File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate? end diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index 85ab786cb..10fa91505 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -110,7 +110,24 @@ class ConfigurationProxyTest < ActiveSupport::TestCase proxy = config.proxy assert_equal "abc:123456", proxy.deploy_options[:"basic-auth"] - assert_includes proxy.deploy_command_args(target: "172.1.0.2"), "--basic-auth=\"abc:123456\"" + + args = proxy.deploy_command_args(target: "172.1.0.2") + assert_match(/--basic-auth=\S*abc:123456/, args.map(&:to_s).join(" ")) + end + + test "basic auth credentials are redacted in command args" do + @deploy[:proxy] = { "basic_auth" => { "username" => "abc", "password" => "123456" } } + + args = config.proxy.deploy_command_args(target: "172.1.0.2") + redacted = Kamal::Utils.redacted(args).join(" ") + + assert_includes redacted, "--basic-auth=[REDACTED]" + assert_not_includes redacted, "123456" + end + + test "basic auth must be a hash" do + @deploy[:proxy] = { "basic_auth" => "abc" } + assert_raises(Kamal::ConfigurationError) { config.proxy } end test "no basic auth option when not configured" do