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..5edecc044 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,12 +100,19 @@ 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 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) @@ -114,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/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..10fa91505 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -105,6 +105,57 @@ 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"] + + 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 + @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)