diff --git a/jobs/haproxy/spec b/jobs/haproxy/spec index ef407dc2..491d25fa 100644 --- a/jobs/haproxy/spec +++ b/jobs/haproxy/spec @@ -24,6 +24,7 @@ templates: blacklist_cidrs.txt.erb: config/blacklist_cidrs.txt blocklist_cidrs_tcp.txt.erb: config/blocklist_cidrs_tcp.txt whitelist_cidrs.txt.erb: config/whitelist_cidrs.txt + domain_ip_allowlist.acl.erb: config/domain_ip_allowlist.acl expect_proxy_cidrs.txt.erb: config/expect_proxy_cidrs.txt trusted_domain_cidrs.txt.erb: config/trusted_domain_cidrs.txt @@ -608,6 +609,16 @@ properties: cidr_whitelist: - 172.168.4.1/32 - 10.2.0.0/16 + ha_proxy.enable_domain_ip_allowlist: + description: "Enables dynamic allowlisting by domain and IP prefix. ACL rules can be updated at runtime via socket." + default: ~ + ha_proxy.domain_ip_allowlist: + description: "List of entries 'domain|IP-version|IP-binary-prefix' to allow requests to the domain for the IP addresses that have the given prefix in binary form. List format is array of strings or single string of base64-encoded gzip data. Ignored if enable_domain_ip_allowlist not set to true." + default: ~ + example: + domain_ip_allowlist: + - .bar.example.com|4|0101010101010 + - foo.bar.example.com|6|010101010101001010101010100101010101010 ha_proxy.expect_proxy_cidrs: description: "List of CIDRs to enable proxy protocol for. This enables the forwarding of the client source IP for hyperscalers that do not support IP dual stack (v4 & v6). This property is mutually exclusive with the accept_proxy. For backward compatibility, if the list is not empty, HAProxy will listen on an additional health check port (health_check_port + 1) with proxy protocol enabled, by implicitly setting enable_additional_health_check_proxy to true if not set explicitly." default: ~ diff --git a/jobs/haproxy/templates/domain_ip_allowlist.acl.erb b/jobs/haproxy/templates/domain_ip_allowlist.acl.erb new file mode 100644 index 00000000..2824d007 --- /dev/null +++ b/jobs/haproxy/templates/domain_ip_allowlist.acl.erb @@ -0,0 +1,25 @@ +# generated from domain_ip_allowlist.acl.erb +<% + require "base64" + require 'zlib' + require 'stringio' + + if_p("ha_proxy.domain_ip_allowlist") do |lines| + uncompressed = '' + if lines.is_a?(Array) + uncompressed << "\# detected ACL entries provided as array in cleartext format\n" + lines.each do |line| + uncompressed << line << "\n" + end + else + gzplain = Base64.decode64(lines) + gz = Zlib::GzipReader.new(StringIO.new(gzplain)) + uncompressed = gz.read + end +%> +# BEGIN domain-ip allowlist +<%= uncompressed %> +# END domain-ip allowlist +<% + end +%> diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index 12ef57d7..273a3c7e 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -289,6 +289,28 @@ end # to keep backward compatibility enable_additional_health_check_proxy if expect_proxy_cidrs is not empty. enable_additional_health_check_proxy = p("ha_proxy.enable_additional_health_check_proxy", p("ha_proxy.expect_proxy_cidrs", []).size > 0) + domain_ip_allowlist_config = <<~TXTBLOCK + +# Checks the request's host header and source IP against an ACL file. +# Each ACL entry has the format: domain|ip-version|ip-binary-prefix +# - A domain starting with "." matches all subdomains (e.g. ".example.com" matches "foo.example.com") +# - ip-binary-prefix is the IP address in binary; a shorter prefix matches a wider range (like CIDR) +# Examples: .bar.example.com|4|010101010101 (IPv4, matches /12 prefix) +# foo.bar.example.com|6|010101...01 (IPv6 exact host) +http-request set-var(req.ip_bin) src,base2 +http-request set-var(req.ip_proto) str(4) +http-request set-var(req.ip_proto) str(6) if { var(req.ip_bin),length gt 32 } +http-request set-var(req.domain) hdr(host),host_only,lower +http-request set-var-fmt(req.domain_key) %[var(req.domain)]|%[var(req.ip_proto)]|%[var(req.ip_bin)] +http-request set-var(req.parent_domain) var(req.domain),regsub(^[^\.]+,) +http-request set-var-fmt(req.parent_domain_key) %[var(req.parent_domain)]|%[var(req.ip_proto)]|%[var(req.ip_bin)] +acl domain_ip_matched var(req.domain_key) -m beg -f opt@/var/vcap/jobs/haproxy/config/domain_ip_allowlist.acl +acl parent_domain_ip_matched var(req.parent_domain_key) -m beg -f opt@/var/vcap/jobs/haproxy/config/domain_ip_allowlist.acl +http-request set-var-fmt(txn.block_reason) "blocked by domain-ip allowlist, key=%[var(req.domain_key)]" if !domain_ip_matched !parent_domain_ip_matched +http-request deny status 403 content-type text/plain string "blocked by domain-ip allowlist" if !domain_ip_matched !parent_domain_ip_matched + + TXTBLOCK + -%> global @@ -475,6 +497,9 @@ frontend http-in acl blacklist src -f /var/vcap/jobs/haproxy/config/blacklist_cidrs.txt tcp-request content reject if blacklist <%- end -%> + <%- if_p("ha_proxy.enable_domain_ip_allowlist") do -%> + <%= format_indented_multiline_config(domain_ip_allowlist_config) %> + <%- end -%> <%- if p("ha_proxy.block_all") -%> tcp-request content reject <%- end -%> @@ -606,6 +631,9 @@ frontend https-in acl blacklist src -f /var/vcap/jobs/haproxy/config/blacklist_cidrs.txt tcp-request content reject if blacklist <%- end -%> + <%- if_p("ha_proxy.enable_domain_ip_allowlist") do -%> + <%= format_indented_multiline_config(domain_ip_allowlist_config) %> + <%- end -%> <%- if p("ha_proxy.block_all") -%> tcp-request content reject <%- end -%> @@ -790,6 +818,9 @@ frontend wss-in acl blacklist src -f /var/vcap/jobs/haproxy/config/blacklist_cidrs.txt tcp-request content reject if blacklist <%- end -%> + <%- if_p("ha_proxy.enable_domain_ip_allowlist") do -%> + <%= format_indented_multiline_config(domain_ip_allowlist_config) %> + <%- end -%> <%- if p("ha_proxy.block_all") -%> tcp-request content reject <%- end -%> diff --git a/spec/haproxy/templates/haproxy_config/domain_ip_allowlist_spec.rb b/spec/haproxy/templates/haproxy_config/domain_ip_allowlist_spec.rb new file mode 100644 index 00000000..91728457 --- /dev/null +++ b/spec/haproxy/templates/haproxy_config/domain_ip_allowlist_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'rspec' + +describe 'config/haproxy.config domain-IP allowlist' do + let(:haproxy_conf) do + parse_haproxy_config(template.render({ 'ha_proxy' => properties })) + end + + let(:frontend_http) { haproxy_conf['frontend http-in'] } + let(:frontend_https) { haproxy_conf['frontend https-in'] } + let(:frontend_wss) { haproxy_conf['frontend wss-in'] } + + let(:default_properties) do + { + 'ssl_pem' => 'ssl pem contents', # required for https-in and wss-in frontends + 'enable_4443' => true # required for wss-in frontend + } + end + + let(:properties) { default_properties } + + context 'when ha_proxy.enable_domain_ip_allowlist is not set' do + it 'does not include domain-IP allowlist rules in http-in' do + expect(frontend_http).not_to include('set-var(req.ip_bin)') + expect(frontend_http).not_to include('domain_ip_allowlist.acl') + expect(frontend_http).not_to include('http-request deny status 403') + end + + it 'does not include domain-IP allowlist rules in https-in' do + expect(frontend_https).not_to include('set-var(req.ip_bin)') + expect(frontend_https).not_to include('domain_ip_allowlist.acl') + end + + it 'does not include domain-IP allowlist rules in wss-in' do + expect(frontend_wss).not_to include('set-var(req.ip_bin)') + expect(frontend_wss).not_to include('domain_ip_allowlist.acl') + end + end + + context 'when ha_proxy.enable_domain_ip_allowlist is true' do + let(:properties) { default_properties.merge('enable_domain_ip_allowlist' => true) } + + it 'converts source IP to binary in http-in' do + expect(frontend_http).to include('http-request set-var(req.ip_bin) src,base2') + end + + it 'sets IP protocol version variable in http-in' do + expect(frontend_http).to include('http-request set-var(req.ip_proto) str(4)') + expect(frontend_http).to include('http-request set-var(req.ip_proto) str(6) if { var(req.ip_bin),length gt 32 }') + end + + it 'extracts domain and parent domain from Host header in http-in' do + expect(frontend_http).to include('http-request set-var(req.domain) hdr(host),host_only,lower') + expect(frontend_http).to include('http-request set-var(req.parent_domain) var(req.domain),regsub(^[^\.]+,)') + end + + it 'builds lookup keys for domain and parent domain in http-in' do + expect(frontend_http).to include('http-request set-var-fmt(req.domain_key) %[var(req.domain)]|%[var(req.ip_proto)]|%[var(req.ip_bin)]') + expect(frontend_http).to include('http-request set-var-fmt(req.parent_domain_key) %[var(req.parent_domain)]|%[var(req.ip_proto)]|%[var(req.ip_bin)]') + end + + it 'defines ACLs against the allowlist file in http-in' do + expect(frontend_http).to include('acl domain_ip_matched var(req.domain_key) -m beg -f opt@/var/vcap/jobs/haproxy/config/domain_ip_allowlist.acl') + expect(frontend_http).to include('acl parent_domain_ip_matched var(req.parent_domain_key) -m beg -f opt@/var/vcap/jobs/haproxy/config/domain_ip_allowlist.acl') + end + + it 'sets block_reason and denies with 403 when neither ACL matches in http-in' do + expect(frontend_http).to include('http-request set-var-fmt(txn.block_reason) "blocked by domain-ip allowlist, key=%[var(req.domain_key)]" if !domain_ip_matched !parent_domain_ip_matched') + expect(frontend_http).to include('http-request deny status 403 content-type text/plain string "blocked by domain-ip allowlist" if !domain_ip_matched !parent_domain_ip_matched') + end + + it 'includes domain-IP allowlist rules in https-in' do + expect(frontend_https).to include('http-request set-var(req.ip_bin) src,base2') + expect(frontend_https).to include('acl domain_ip_matched var(req.domain_key) -m beg -f opt@/var/vcap/jobs/haproxy/config/domain_ip_allowlist.acl') + expect(frontend_https).to include('acl parent_domain_ip_matched var(req.parent_domain_key) -m beg -f opt@/var/vcap/jobs/haproxy/config/domain_ip_allowlist.acl') + expect(frontend_https).to include('http-request deny status 403 content-type text/plain string "blocked by domain-ip allowlist" if !domain_ip_matched !parent_domain_ip_matched') + end + + it 'includes domain-IP allowlist rules in wss-in' do + expect(frontend_wss).to include('http-request set-var(req.ip_bin) src,base2') + expect(frontend_wss).to include('acl domain_ip_matched var(req.domain_key) -m beg -f opt@/var/vcap/jobs/haproxy/config/domain_ip_allowlist.acl') + expect(frontend_wss).to include('acl parent_domain_ip_matched var(req.parent_domain_key) -m beg -f opt@/var/vcap/jobs/haproxy/config/domain_ip_allowlist.acl') + expect(frontend_wss).to include('http-request deny status 403 content-type text/plain string "blocked by domain-ip allowlist" if !domain_ip_matched !parent_domain_ip_matched') + end + end +end