HAProxy Security Hardening: Managing Mixed Internal and External Traffic
The Problem: "Ghost" Connections from the Outside
When hosting both internal-only services (e.g., NAS, hypervisors) and external-facing applications (e.g., smart home dashboards) behind a single reverse proxy, you might notice external IPs attempting to connect to your internal-only services.
Even if an internal service's domain (e.g., nas.internal.example.com) is only resolvable via a local DNS server, external bots can still find it. When you generate an SSL certificate (like Let's Encrypt), the domain is logged in public Certificate Transparency (CT) logs. Bots scrape these logs, take the newly registered domain, and send a crafted HTTP request with the specific Host header directly to your public IP.
If the reverse proxy is not configured to check the source IP, it reads the Host header and blindly forwards the traffic to the internal service.
The Solution: Source IP Validation via ACLs
To prevent this, the reverse proxy must validate not only where the traffic is going (the Host header), but also where it is coming from (the source IP).
HAProxy Configuration Example
In your haproxy.cfg, define an Access Control List (ACL) containing your trusted local subnets. Then, enforce a rule that explicitly denies access to internal-only backends if the source IP does not belong to the trusted ACL.
frontend https_front
bind *:443 ssl crt /etc/haproxy/certs/
mode http
option forwardfor
http-request set-header X-Forwarded-Proto https
# 1. Define trusted local networks
acl is_local_network src 192.168.1.0/24 10.0.0.0/24 127.0.0.1
# 2. Configuration for INTERNAL-ONLY service (e.g., NAS, Firewall)
acl host_nas hdr(host) -i nas.internal.example.com
# DENY the request immediately if it matches the internal host but comes from outside
http-request deny if host_nas !is_local_network
use_backend nas_backend if host_nas
acl host_fw hdr(host) -i firewall.internal.example.com
http-request deny if host_fw !is_local_network
use_backend fw_backend if host_fw
# 3. Configuration for EXTERNAL service (e.g., Home Assistant)
acl host_app hdr(host) -i app.external.example.com
# No deny rule here - external access is allowed
use_backend app_backend if host_app
Verification (Simulating an Attack)
To test this configuration, disconnect from the local network (e.g., use a mobile cellular hotspot). Using an API tool like Postman:
- Send a GET request to your public-facing application URL (e.g., https://app.external.example.com).
- Override the default Host header manually and set its value to your internal domain (e.g., nas.internal.example.com).
- Expected Result: The request should be immediately rejected by HAProxy with a 403 Forbidden error, proving the external block is working.
For example, to test access to nas.internal.example.com:
curl -v -k -H "Host: nas.internal.example.com" https://public-ip/path
This command will send a request to the public IP address of the HAProxy server, but with the Host header set to nas.internal.example.com. HAProxy should detect that this is an internal-only service and reject the request.
Expected Output:
* Trying [IP_ADDRESS]...
* Connected to [IP_ADDRESS] (port 443) (#1)
* ALPN, server accepted to use h2
* ALPN, offering h2
* ALPN, server accepted to use h2
* ALPN, server accepted to use HTTP/1.1
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* Server certificate:
* subject: CN=...
* start date: 2024-01-01 00:00:00 GMT
* expire date: 2024-04-01 00:00:00 GMT
* issuer: C=US; O=Let's Encrypt; CN=R3
* SSL certificate verify result: certificate has expired (60)
> GET / HTTP/2
> Host: nas.internal.example.com
> User-Agent: curl/7.68.0
> Accept: */*
>
* Connection state changed (HTTP/2 confirmed)
< HTTP/1.1 403 Forbidden
< Content-Length: 0
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Referrer-Policy: strict-origin-when-cross-origin
< Strict-Transport-Security: max-age=31536000; includeSubDomains
< Date: Thu, 01 Feb 2024 12:00:00 GMT
< Server: haproxy
<
* Connection #1 to host [IP_ADDRESS] left intact
This output shows that HAProxy intercepted the request and returned a 403 Forbidden error, effectively blocking the connection. If the request was successful, HAProxy would have forwarded it to the backend server and returned a 200 OK response.
To verify the internal connection, try accessing the same service from within the local network, but without the -H flag:
curl -v -k https://nas.internal.example.com/path
Expected Output:
* Trying [IP_ADDRESS]...
* Connected to [IP_ADDRESS] (port 443) (#1)
* ALPN, server accepted to use h2
* ALPN, offering h2
* ALPN, server accepted to use h2
* ALPN, server accepted to use HTTP/1.1
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* Server certificate:
* subject: CN=...
* start date: 2024-01-01 00:00:00 GMT
* expire date: 2024-04-01 00:00:00 GMT
* issuer: C=US; O=Let's Encrypt; CN=R3
* SSL certificate verify result: certificate has expired (60)
> GET / HTTP/2
> Host: nas.internal.example.com
> User-Agent: curl/7.68.0
> Accept: */*
>
* Connection state changed (HTTP/2 confirmed)
< HTTP/1.1 200 OK
< Content-Length: 0
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Referrer-Policy: strict-origin-when-cross-origin
< Strict-Transport-Security: max-age=31536000; includeSubDomains
< Date: Thu, 01 Feb 2024 12:00:00 GMT
< Server: haproxy
<
* Connection #1 to host [IP_ADDRESS] left intact