Skip to content
Open
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
14 changes: 14 additions & 0 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 26 additions & 2 deletions lib/kamal/configuration/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
r4mbo7 marked this conversation as resolved.

def deploy_options
{
host: hosts,
Expand All @@ -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)
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/kamal/configuration/validator/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +24 to +28

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good instinct, but this is already covered: the proxy config is schema-validated against docs/proxy.yml, where basic_auth is defined as a hash. A string/boolean/array value raises proxy/basic_auth: should be a hash before basic_auth? is ever reached, so it fails closed rather than silently ignoring the misconfiguration. I've added a test ("basic auth must be a hash") to make that explicit and prevent regressions.

Comment on lines +24 to +28

if run_config = config["run"]
if run_config["bind_ips"].present?
ensure_valid_bind_ips(config["bind_ips"])
Expand Down
51 changes: 51 additions & 0 deletions test/configuration/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down