Skip to content
Draft
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
11 changes: 11 additions & 0 deletions jobs/haproxy/spec
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: ~
Expand Down
25 changes: 25 additions & 0 deletions jobs/haproxy/templates/domain_ip_allowlist.acl.erb
Original file line number Diff line number Diff line change
@@ -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
%>
31 changes: 31 additions & 0 deletions jobs/haproxy/templates/haproxy.config.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -%>
Expand Down Expand Up @@ -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 -%>
Expand Down Expand Up @@ -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 -%>
Expand Down
Original file line number Diff line number Diff line change
@@ -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