From dbb08441978a86a4fe28f928e4d588ae2669c011 Mon Sep 17 00:00:00 2001 From: Clemens Hoffmann Date: Thu, 26 Feb 2026 16:52:05 +0100 Subject: [PATCH 1/3] Introduce blocking on tcp layer --- jobs/haproxy/spec | 8 ++ .../templates/blocklist_cidrs_tcp.txt.erb | 20 +++++ jobs/haproxy/templates/haproxy.config.erb | 4 + .../templates/blocklist_cidrs_tcp.txt_spec.rb | 78 +++++++++++++++++++ .../haproxy_config/frontend_http_spec.rb | 33 ++++++++ .../haproxy_config/frontend_https_spec.rb | 33 ++++++++ 6 files changed, 176 insertions(+) create mode 100644 jobs/haproxy/templates/blocklist_cidrs_tcp.txt.erb create mode 100644 spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb diff --git a/jobs/haproxy/spec b/jobs/haproxy/spec index 0ec512a8..ef407dc2 100644 --- a/jobs/haproxy/spec +++ b/jobs/haproxy/spec @@ -22,6 +22,7 @@ templates: backend-crt.erb: config/backend-crt.pem client-revocation-list.erb: config/client-revocation-list.pem 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 expect_proxy_cidrs.txt.erb: config/expect_proxy_cidrs.txt trusted_domain_cidrs.txt.erb: config/trusted_domain_cidrs.txt @@ -593,6 +594,13 @@ properties: cidr_blacklist: - 10.0.0.0/8 - 192.168.2.0/24 + ha_proxy.cidr_blocklist_tcp: + description: List of CIDRs to block on TCP level. If empty, only a comment is rendered. Format is string array of CIDRs or single string of base64 encoded gzip. + default: ~ + example: + cidr_blocklist_tcp: + - 10.0.0.0/8 + - 192.168.2.0/24 ha_proxy.cidr_whitelist: description: "List of CIDRs to allow for http(s). Format is string array of CIDRs or single string of base64 encoded gzip. Note that unless ha_proxy.block_all is true, non-whitelisted traffic will still be allowed, provided that traffic is not also blacklisted" default: ~ diff --git a/jobs/haproxy/templates/blocklist_cidrs_tcp.txt.erb b/jobs/haproxy/templates/blocklist_cidrs_tcp.txt.erb new file mode 100644 index 00000000..5707d323 --- /dev/null +++ b/jobs/haproxy/templates/blocklist_cidrs_tcp.txt.erb @@ -0,0 +1,20 @@ +# generated from blocklist_cidrs_tcp.txt.erb +<% +require "base64" +require 'zlib' +require 'stringio' + +cidrs = p("ha_proxy.cidr_blocklist_tcp", []) +uncompressed = '' +if cidrs.is_a?(Array) && cidrs.any? + cidrs.each do |cidr| + uncompressed << cidr << "\n" + end +elsif cidrs.is_a?(String) + gzplain = Base64.decode64(cidrs) + gz = Zlib::GzipReader.new(StringIO.new(gzplain)) + uncompressed = gz.read +end +%> +# This list contains CIDRs that are blocked immediately after TCP connection setup. +<%= uncompressed %> diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index bbe8b578..07c14cf5 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -428,6 +428,8 @@ frontend http-in <%- if properties.ha_proxy.frontend_config -%> <%= format_indented_multiline_config(p("ha_proxy.frontend_config")) %> <%- end -%> + acl layer4_block src -f /var/vcap/jobs/haproxy/config/blocklist_cidrs_tcp.txt + tcp-request connection reject if layer4_block <%- if_p("ha_proxy.connections_rate_limit.table_size", "ha_proxy.connections_rate_limit.window_size") do -%> tcp-request connection track-sc0 src table st_tcp_conn_rate <%- if_p("ha_proxy.connections_rate_limit.block", "ha_proxy.connections_rate_limit.connections") do |block, connections| -%> @@ -560,6 +562,8 @@ frontend https-in <%- if properties.ha_proxy.frontend_config -%> <%= format_indented_multiline_config(p("ha_proxy.frontend_config")) %> <%- end -%> + acl layer4_block src -f /var/vcap/jobs/haproxy/config/blocklist_cidrs_tcp.txt + tcp-request connection reject if layer4_block <%- if_p("ha_proxy.connections_rate_limit.table_size", "ha_proxy.connections_rate_limit.window_size") do -%> tcp-request connection track-sc0 src table st_tcp_conn_rate <%- if_p("ha_proxy.connections_rate_limit.block", "ha_proxy.connections_rate_limit.connections") do |block, connections| -%> diff --git a/spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb b/spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb new file mode 100644 index 00000000..3d1833f4 --- /dev/null +++ b/spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rspec' + +describe 'config/blocklist_cidrs_tcp.txt' do + let(:template) { haproxy_job.template('config/blocklist_cidrs_tcp.txt') } + + context 'when ha_proxy.cidr_blocklist_tcp is provided' do + context 'when an array of cidrs is provided' do + it 'has the correct contents' do + expect(template.render({ + 'ha_proxy' => { + 'cidr_blocklist_tcp' => [ + '10.0.0.0/8', + '192.168.2.0/24' + ] + } + })).to eq(<<~EXPECTED) + # generated from blocklist_cidrs_tcp.txt.erb + + # This list contains CIDRs that are blocked immediately after TCP connection setup. + 10.0.0.0/8 + 192.168.2.0/24 + + EXPECTED + end + end + + context 'when a base64-encoded, gzipped config is provided' do + it 'has the correct contents' do + expect(template.render({ + 'ha_proxy' => { + 'cidr_blocklist_tcp' => gzip_and_b64_encode(<<~INPUT) + 10.0.0.0/8 + 192.168.2.0/24 + INPUT + } + })).to eq(<<~EXPECTED) + # generated from blocklist_cidrs_tcp.txt.erb + + # This list contains CIDRs that are blocked immediately after TCP connection setup. + 10.0.0.0/8 + 192.168.2.0/24 + + EXPECTED + end + end + end + + context 'when ha_proxy.cidr_blocklist_tcp is not provided' do + it 'contains only the default comment' do + expect(template.render({})).to eq(<<~EXPECTED) + # generated from blocklist_cidrs_tcp.txt.erb + + # This list contains CIDRs that are blocked immediately after TCP connection setup. + + EXPECTED + end + end + + context 'when ha_proxy.cidr_blocklist_tcp is an empty array' do + it 'contains only the default comment' do + expect(template.render({ + 'ha_proxy' => { + 'cidr_blocklist_tcp' => [] + } + })).to eq(<<~EXPECTED) + # generated from blocklist_cidrs_tcp.txt.erb + + # This list contains CIDRs that are blocked immediately after TCP connection setup. + + EXPECTED + end + end +end + + + diff --git a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb index 0af62f5c..f73bfb4c 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb @@ -122,6 +122,39 @@ end end + context 'TCP level blocklist (layer4_block)' do + context 'when ha_proxy.cidr_blocklist_tcp is provided' do + let(:properties) do + { 'cidr_blocklist_tcp' => ['172.168.4.1/32', '10.2.0.0/16'] } + end + + it 'sets the correct acl and connection reject rules' do + expect(frontend_http).to include('acl layer4_block src -f /var/vcap/jobs/haproxy/config/blocklist_cidrs_tcp.txt') + expect(frontend_http).to include('tcp-request connection reject if layer4_block') + end + end + + context 'when ha_proxy.cidr_blocklist_tcp is not provided' do + let(:properties) { {} } + + it 'still includes the layer4_block acl and reject rule (always present)' do + expect(frontend_http).to include('acl layer4_block src -f /var/vcap/jobs/haproxy/config/blocklist_cidrs_tcp.txt') + expect(frontend_http).to include('tcp-request connection reject if layer4_block') + end + end + + context 'when ha_proxy.cidr_blocklist_tcp is an empty array' do + let(:properties) do + { 'cidr_blocklist_tcp' => [] } + end + + it 'still includes the layer4_block acl and reject rule (always present)' do + expect(frontend_http).to include('acl layer4_block src -f /var/vcap/jobs/haproxy/config/blocklist_cidrs_tcp.txt') + expect(frontend_http).to include('tcp-request connection reject if layer4_block') + end + end + end + context 'when ha_proxy.block_all is provided' do let(:properties) do { 'block_all' => true } diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index 952e455f..08fee8e7 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -554,6 +554,39 @@ end end + context 'TCP level blocklist (layer4_block)' do + context 'when ha_proxy.cidr_blocklist_tcp is provided' do + let(:properties) do + default_properties.merge({ 'cidr_blocklist_tcp' => ['172.168.4.1/32', '10.2.0.0/16'] }) + end + + it 'sets the correct acl and connection reject rules' do + expect(frontend_https).to include('acl layer4_block src -f /var/vcap/jobs/haproxy/config/blocklist_cidrs_tcp.txt') + expect(frontend_https).to include('tcp-request connection reject if layer4_block') + end + end + + context 'when ha_proxy.cidr_blocklist_tcp is not provided' do + let(:properties) { default_properties } + + it 'still includes the layer4_block acl and reject rule (always present)' do + expect(frontend_https).to include('acl layer4_block src -f /var/vcap/jobs/haproxy/config/blocklist_cidrs_tcp.txt') + expect(frontend_https).to include('tcp-request connection reject if layer4_block') + end + end + + context 'when ha_proxy.cidr_blocklist_tcp is an empty array' do + let(:properties) do + default_properties.merge({ 'cidr_blocklist_tcp' => [] }) + end + + it 'still includes the layer4_block acl and reject rule (always present)' do + expect(frontend_https).to include('acl layer4_block src -f /var/vcap/jobs/haproxy/config/blocklist_cidrs_tcp.txt') + expect(frontend_https).to include('tcp-request connection reject if layer4_block') + end + end + end + context 'when ha_proxy.block_all is provided' do let(:properties) do default_properties.merge({ 'block_all' => true }) From 9a63600dcac5d6c65cc727ccb9f65c1128be8a82 Mon Sep 17 00:00:00 2001 From: Alexander Nicke Date: Thu, 5 Mar 2026 14:08:27 +0100 Subject: [PATCH 2/3] test: Acceptance test for TCP-layer CIDR blocklist --- acceptance-tests/access_control_test.go | 51 +++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/acceptance-tests/access_control_test.go b/acceptance-tests/access_control_test.go index 4d911e31..de101914 100644 --- a/acceptance-tests/access_control_test.go +++ b/acceptance-tests/access_control_test.go @@ -5,16 +5,17 @@ import ( "fmt" "io" "net/http" + "syscall" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) /* - Test strategy: - * Use an SSH tunnel to make requests to HAProxy that appear to come from 127.0.0.1 - * Requests directly from test runner on Concourse appear to come from 10.0.0.0/8 - We can test whitelisting and blacklisting by using these CIDRs +Test strategy: + - Use an SSH tunnel to make requests to HAProxy that appear to come from 127.0.0.1 + - Requests directly from test runner on Concourse appear to come from 10.0.0.0/8 + We can test whitelisting and blacklisting by using these CIDRs */ var _ = Describe("Access Control", func() { opsfileWhitelist := `--- @@ -32,6 +33,12 @@ var _ = Describe("Access Control", func() { path: /instance_groups/name=haproxy/jobs/name=haproxy/properties/ha_proxy/cidr_blacklist? value: ((cidr_blacklist)) ` + opsfileTCPBlocklist := `--- +# Enable TCP-layer CIDR blacklist +- type: replace + path: /instance_groups/name=haproxy/jobs/name=haproxy/properties/ha_proxy/cidr_blocklist_tcp? + value: ((cidr_blocklist_tcp)) +` It("Allows IPs in whitelisted CIDRS", func() { haproxyBackendPort := 12000 @@ -97,4 +104,40 @@ var _ = Describe("Access Control", func() { By("Allowing access to non-blacklisted CIDRs (request from 127.0.0.1 on HAProxy VM)") expectTestServer200(http.Get("http://127.0.0.1:11000")) }) + + It("Rejects IPs in TCP-layer blocklisted CIDRs", func() { + haproxyBackendPort := 12000 + + haproxyInfo, _ := deployHAProxy(baseManifestVars{ + haproxyBackendPort: haproxyBackendPort, + haproxyBackendServers: []string{"127.0.0.1"}, + deploymentName: deploymentNameForTestNode(), + }, []string{opsfileTCPBlocklist}, map[string]interface{}{ + // traffic from test runner appears to come from this CIDR block + "cidr_blocklist_tcp": []string{"10.0.0.0/8"}, + }, true) + + closeLocalServer, localPort := startDefaultTestServer() + defer closeLocalServer() + + closeBackendTunnel := setupTunnelFromHaproxyToTestServer(haproxyInfo, haproxyBackendPort, localPort) + defer closeBackendTunnel() + + // Set up a tunnel so that requests to localhost:11000 appear to come from 127.0.0.1 + // on the HAProxy VM, which is NOT in the TCP blocklist + closeFrontendTunnel := setupTunnelFromLocalMachineToHAProxy(haproxyInfo, 11000, 80) + defer closeFrontendTunnel() + + By("Denying TCP connections from blocklisted CIDRs (request from test runner)") + resp, err := http.Get(fmt.Sprintf("http://%s", haproxyInfo.PublicIP)) + if err == nil { + _, err = io.ReadAll(resp.Body) + _ = resp.Body.Close() + } + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, syscall.ECONNRESET)).To(BeTrue()) + + By("Allowing TCP connections from non-blocklisted CIDRs (request from 127.0.0.1 on HAProxy VM)") + expectTestServer200(http.Get("http://127.0.0.1:11000")) + }) }) From af1afdecb8b531ded38872c033c12898c949f81f Mon Sep 17 00:00:00 2001 From: Alexander Nicke Date: Mon, 9 Mar 2026 11:59:45 +0100 Subject: [PATCH 3/3] fix: linter issues --- spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb | 3 --- spec/haproxy/templates/haproxy_config/frontend_http_spec.rb | 2 +- spec/haproxy/templates/haproxy_config/frontend_https_spec.rb | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb b/spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb index 3d1833f4..de00f243 100644 --- a/spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb +++ b/spec/haproxy/templates/blocklist_cidrs_tcp.txt_spec.rb @@ -73,6 +73,3 @@ end end end - - - diff --git a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb index f73bfb4c..f9cfb3ab 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb @@ -122,7 +122,7 @@ end end - context 'TCP level blocklist (layer4_block)' do + context 'with TCP level blocklist (layer4_block)' do context 'when ha_proxy.cidr_blocklist_tcp is provided' do let(:properties) do { 'cidr_blocklist_tcp' => ['172.168.4.1/32', '10.2.0.0/16'] } diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index 08fee8e7..2645fcc3 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -554,7 +554,7 @@ end end - context 'TCP level blocklist (layer4_block)' do + context 'with TCP level blocklist (layer4_block)' do context 'when ha_proxy.cidr_blocklist_tcp is provided' do let(:properties) do default_properties.merge({ 'cidr_blocklist_tcp' => ['172.168.4.1/32', '10.2.0.0/16'] })