feat(edge): static default splash for unrouted hosts, answered before the WAF (2026.11.1)

Visiting a server's own hostname (or its IP, or any unrouted name) on 80/443
could show the Coraza WAF block page. `use_backend default-backend` does not
short-circuit http-request rules, so the catch-all was WAF-inspected with the
full server-wide rule set like customer traffic; a browser on the server
hostname also carries the control panel's cookies (not port-scoped).

Unrouted requests are now answered by HAProxy itself with a tiny static page
(errors/default-splash.html via `http-request return ... file`; 200 on "/",
404 elsewhere) placed before the SPOE call. "Routed" comes from
routed_hosts.list / routed_wildcards.list, written by generate_config() from
the same rows as the routing ACLs, port-stripped + lowercased (superset of the
raw-Host routing ACLs), and part of the rollback set. ACME http-01, blocked
IPs and suspended hosts keep their existing handling; rate limits and gates
still run first. HAPROXY_DEFAULT_SPLASH=off restores the old behaviour.

Page and template live in the image outside the /etc/haproxy named volume;
the lists are generated on start, so image bump + recreate is the deploy.

Tests: scripts/test-default-splash.py (in the image build), new splash-off
scenario in validate-rendered-config.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXjXyosSkunDsdWz3mb
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-08 06:55:42 -08:00
1 parent 1ee5760c1b
commit 8787e0d99e
9 files changed
+471 -3

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **API thread-pool safety**: `python3 scripts/test-gunicorn-socket-timeouts.py` - runs REAL gunicorn (~60s); asserts a silent/half-written client cannot pin a gthread pool thread (per-connection socket timeout, `HAPROXY_MGR_CLIENT_TIMEOUT`), 2050 never-sending connections are reaped (`HAPROXY_MGR_FIRST_BYTE_TIMEOUT`), slowloris header drips are cut (`HAPROXY_MGR_HEADER_DEADLINE`, counted from enqueue), 40 reconnecting drippers cannot starve legit requests, the :8000 peer filter (`HAPROXY_MGR_API_ALLOWED_PEERS`) refuses non-allowed peers, slow legit requests are not cut off, the wedge watchdog (`HAPROXY_MGR_WEDGE_SECONDS`) replaces a fully stuck worker, and an in-process call to a stalled upstream times out. Run it after touching `haproxy_gunicorn_worker.py`, start-up.sh's gunicorn lines, or upgrading gunicorn (the worker overrides gthread internals: `enqueue_req`, `accept`, `on_client_socket_readable`, `murder_keepalived`, `handle`, `handle_request`).
- **WAF body inspection**: `python3 scripts/test-waf-body-inspection.py` - offline (runs in the image build); pins `option http-buffer-request`, the `X-WHP-WAF-Body` complete/partial stamp (strip -> stamp -> SPOE -> strip), `tune.bufsize` against the 100 ms SPOE fail-open budget and the 65535-byte SPOP frame cap, and Coraza's body limits in `coraza-spoa/config.yaml`. Run it after touching the Coraza block, `tune.bufsize`, or the SPOE engine.
- **Superseded-cert retirement**: covered by the same file (`TestRetirementRules`, `TestSanOverlapRetirement`). A new bundle retires any OTHER pem/lineage sharing ANY name (CN or SAN) with it - the pem moved to `cert-retired/`, the lineage's live+archive+renewal moved TOGETHER to `/etc/letsencrypt/retired/<stamp>/` (leaving archive/ behind makes certbot refuse the name forever), never deleted; the bundle's own `--cert-name` lineage is `live/<primary>` exactly and is never retired - unless that cert is still the only valid one for a name this edge serves (`retirement_blockers()`). `GET /api/capabilities` advertises this to WHP, which must not use whole-site re-bundling without it. Expired pems already in the crt dir are moved to `cert-retired/` by `cert_retire_expired_pems` (never the last pem, never `default_self_signed_cert.pem`). Expired lineages are never published (`cert_is_expired` in `cert-publish-lib.sh`, `leaf_is_expired()` in Python). Fixtures: `scripts/test-fixtures/san-certs/`.
- **Default splash**: `python3 scripts/test-default-splash.py` - offline (runs in the image build); unrouted hosts (server hostname, bare IP, unknown names) are answered by `http-request return ... file /haproxy/errors/default-splash.html` BEFORE the Coraza SPOE call, never for a routed host (`routed_hosts.list`/`routed_wildcards.list`, written by `generate_config()` from the routing rows and in the rollback set), never for ACME/blocked/suspended. Run it after touching the splash block, the routing ACL templates, or the domain loops in `generate_config()`. See README "Default Splash".
- **Manual Testing**: Run `curl` commands against `http://localhost:8000` endpoints as shown in README.md
### Reading stick tables (and why it is easy to get silently wrong)
+1
View File
@@ -105,6 +105,7 @@ RUN pip install -r requirements.txt
RUN python3 /haproxy/scripts/test-wpadmin-gate.py \
&& python3 /haproxy/scripts/test-trusted-proxy-gate.py \
&& python3 /haproxy/scripts/test-xmlrpc-rate-limit.py \
&& python3 /haproxy/scripts/test-default-splash.py \
&& python3 /haproxy/scripts/test-config-rollback.py \
&& python3 /haproxy/scripts/test-cert-write-safety.py \
&& python3 /haproxy/scripts/test-cert-scripts.py \
+67
View File
@@ -379,9 +379,76 @@ tail -f /var/log/haproxy-manager-errors.log
| `HAPROXY_DEFAULT_PAGE_TITLE` | Title for the default page | Site Not Configured |
| `HAPROXY_DEFAULT_MAIN_MESSAGE` | Main message on the default page | This domain has not been configured yet. Please contact your system administrator to set up this website. |
| `HAPROXY_DEFAULT_SECONDARY_MESSAGE` | Secondary message on the default page | If you believe this is an error, please check the domain name and try again. |
| `HAPROXY_DEFAULT_SPLASH` | `off` (or `0`/`false`/`no`) disables the default splash for unrouted hosts and restores the default-backend page below | on |
## Default Splash (unrouted hosts)
Since 2026.11.1, any request whose Host is **not routed to a site** -- the
server's own hostname (registered cert-only, no backend), a bare IP, a stale
or unknown name, a request with no Host at all -- is answered by HAProxy
itself with the static page `errors/default-splash.html` (`http-request
return ... file`): `200` for `/`, `404` with the same page for any other
path, `cache-control: no-store`, `x-content-type-options: nosniff`. The page
names no version, echoes nothing from the request (it is loaded with `file`,
not `lf-file`), and links nowhere -- in particular not to the control panel.
**Why.** `use_backend default-backend` does not short-circuit HAProxy's
`http-request` rules, so the old catch-all was inspected by Coraza with the
full server-wide rule set like customer traffic. A browser on
`https://<server-hostname>/` also sends the control panel's cookies (cookies
are not port-scoped, so `:8443` cookies go to `:443`), and anything Coraza
matched there showed the WAF block page for a host with no site behind it.
The splash rules sit before the SPOE call, so unrouted requests never reach
the WAF; there is nothing behind them to protect.
**What still applies / is kept out:** rate limits, the IP block (blocked IPs
still get the blocked-ip page), the wp-* gates, and the multipart guard run
first; `/.well-known/acme-challenge/` still reaches certbot (the server
hostname's own certificate is issued through it); hosts in
`suspended_domains.list` still get the suspended page. Configured sites,
wildcards and their WAF inspection are unchanged. Plain HTTP on `:80` gets
the splash too, not a redirect: an unknown name usually has no valid
certificate, so a redirect would trade a clean page for a TLS warning.
Over TLS the presented certificate is whatever SNI selects -- the
hostname's own cert if one is issued, otherwise the default bundle.
**How "routed" is decided.** `generate_config()` writes
`/etc/haproxy/routed_hosts.list` (exact names) and
`/etc/haproxy/routed_wildcards.list` (`.base` suffixes) from the same domain
rows, in the same call, as the routing ACLs, and both are in the rollback set
next to `haproxy.cfg`. The splash ACLs compare the Host port-stripped and
lowercased, a superset of the routing ACLs (which compare the raw Host), so a
host that routes is never splashed. Never hand-edit the lists; they are
rewritten on every regeneration (also when the splash is off, so turning it
on never meets a missing `-f` file).
**Deploying to an existing host.** The page ships at
`/haproxy/errors/default-splash.html` and the template under
`/haproxy/templates/` -- both in the image, OUTSIDE the `/etc/haproxy` named
volume, so the volume cannot shadow them. The lists live in the volume but are
generated, not copied. So an image bump plus a recreate of the container is
the whole deployment: on start `generate_config()` renders the new listener
and writes both lists, then HAProxy loads them. Nothing to copy into the
volume, no WHP-side change. Verify after the recreate:
```bash
docker exec haproxy-manager grep -c default-splash.html /etc/haproxy/haproxy.cfg # 2
docker exec haproxy-manager cat /etc/haproxy/routed_hosts.list # every site
curl -sk -o /dev/null -w '%{http_code}\n' https://<server-hostname>/ # 200, splash
curl -sk -o /dev/null -w '%{http_code}\n' https://<a-customer-site>/ # unchanged
```
Rollback: recreate on the previous image tag, or set
`HAPROXY_DEFAULT_SPLASH=off` and regenerate.
Covered by `scripts/test-default-splash.py` (template order/conditions, the
page, list generation) and the `splash-off` scenario of
`scripts/validate-rendered-config.py`.
## Default Backend Configuration
With the default splash on (above), unrouted hosts no longer reach this page; it still serves the blocked-IP and suspended pages, and serves unrouted hosts again when `HAPROXY_DEFAULT_SPLASH=off`.
When a domain is accessed that hasn't been configured in HAProxy, the system will serve a default page instead of showing an error. This default page:
- Informs visitors that the site is not configured
+1 -1
View File
@@ -1 +1 @@
2026.10.7
2026.11.1
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<!--
Default splash: served by HAProxy itself (`http-request return ... file`,
hap_listener.tpl) for any request whose Host is not routed to a site --
the server's own hostname, a bare IP, an unconfigured name. Static on
purpose: no version, no hostname echo, no customer data, no links (in
particular none into the control panel). Loaded with `file`, not
`lf-file`, so `%` is literal here and nothing from the request is
expanded into it. Must stay well under tune.bufsize (16 KB) or HAProxy
rejects the config.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Web Hosting Platform</title>
<style>
:root{color-scheme:light dark;--bg:#f4f5f7;--card:#fff;--fg:#1d2330;--muted:#5b6475;--line:#dfe3ea}
@media (prefers-color-scheme:dark){:root{--bg:#0f1217;--card:#181c23;--fg:#e6e9ef;--muted:#9aa3b2;--line:#2a303a}}
html,body{margin:0;height:100%}
body{display:flex;align-items:center;justify-content:center;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:16px;box-sizing:border-box}
main{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:32px 28px;max-width:480px;width:100%;text-align:center}
h1{font-size:1.25rem;margin:0 0 .5rem}
p{margin:0;color:var(--muted)}
</style>
</head>
<body>
<main>
<h1>Web Hosting Platform</h1>
<p>This server is operated by a web hosting platform. There is no website configured at this address.</p>
</main>
</body>
</html>
+54 -1
View File
@@ -135,6 +135,13 @@ BLOCKED_IPS_MAP_BACKUP_PATH = '/etc/haproxy/blocked_ips.map.backup'
# while leaving a broken coraza-spoe.cfg behind still fails validation.
CORAZA_SPOE_CONFIG_PATH = '/etc/haproxy/coraza-spoe.cfg'
CORAZA_SPOE_BACKUP_PATH = '/etc/haproxy/coraza-spoe.cfg.backup'
# Hosts the frontend routes to a site (default splash, hap_listener.tpl). The
# paths are hardcoded in the template's `-f` ACLs, like blocked_ips.map, and
# written by generate_config() from the same rows as the routing ACLs.
ROUTED_HOSTS_PATH = '/etc/haproxy/routed_hosts.list'
ROUTED_HOSTS_BACKUP_PATH = '/etc/haproxy/routed_hosts.list.backup'
ROUTED_WILDCARDS_PATH = '/etc/haproxy/routed_wildcards.list'
ROUTED_WILDCARDS_BACKUP_PATH = '/etc/haproxy/routed_wildcards.list.backup'
HAPROXY_SOCKET_PATH = '/var/run/haproxy.sock'
# HAProxy loads this path as a DIRECTORY (`bind ... ssl crt /etc/haproxy/certs`),
# which means it tries to load EVERY file it finds in here. Nothing but final,
@@ -3148,6 +3155,14 @@ def generate_config():
# handling.
syslog_target = os.environ.get('HAPROXY_SYSLOG_TARGET', '172.18.0.1:514').strip()
# Default splash for unrouted hosts (server hostname, bare IP, unknown
# names) answered by HAProxy before the WAF -- see hap_listener.tpl.
# ON by default: it depends on no sidecar and only changes what an
# unrouted request sees. HAPROXY_DEFAULT_SPLASH=off restores the old
# default-backend page (WAF-inspected).
splash_raw = os.environ.get('HAPROXY_DEFAULT_SPLASH', '').strip().lower()
default_splash = splash_raw not in ('0', 'false', 'no', 'off')
# Add Haproxy Default Headers
default_headers = template_env.get_template('hap_header.tpl').render(
cluster_secret = get_or_create_cluster_secret(),
@@ -3168,6 +3183,7 @@ def generate_config():
crt_path = SSL_CERTS_DIR,
coraza_spoe_backend = coraza_spoe_backend,
suspension_enabled = suspension_enabled,
default_splash = default_splash,
)
config_parts.append(listener_block)
@@ -3176,7 +3192,10 @@ def generate_config():
config_parts.append(letsencrypt_acl)
config_acls = []
config_backends = []
# Every name a routing ACL is rendered for (default splash lists).
routed_hosts = []
routed_wildcards = []
# Add default backend rule (will be used when no domain matches)
default_rule = " # Default backend for unmatched domains\n default_backend default-backend\n"
config_parts.append(default_rule)
@@ -3231,6 +3250,7 @@ def generate_config():
name=domain['backend_name']
)
config_acls.append(domain_acl)
routed_hosts.append(domain['domain'])
logger.info(f"Added ACL for domain: {domain['domain']}")
except Exception as e:
logger.error(f"Error generating domain ACL for {domain['domain']}: {e}")
@@ -3257,6 +3277,7 @@ def generate_config():
base_domain=base_domain
)
config_acls.append(domain_acl)
routed_wildcards.append('.' + base_domain)
logger.info(f"Added wildcard ACL for domain: {domain['domain']}")
except Exception as e:
logger.error(f"Error generating wildcard ACL for {domain['domain']}: {e}")
@@ -3318,6 +3339,14 @@ backend default-backend
f"{CORAZA_SPOE_CONFIG_PATH} "
f"(SPOA target: {coraza_spoe_backend})")
# Routed-host lists for the default splash. Written on every render
# (also with the splash off, so turning it on later never meets a
# missing `-f` file, which is FATAL). Same render, same rows as the
# routing ACLs above, and in the rollback set, so the loaded
# haproxy.cfg and these lists always come from one generation.
write_config_atomically(ROUTED_HOSTS_PATH, render_routed_list(routed_hosts))
write_config_atomically(ROUTED_WILDCARDS_PATH, render_routed_list(routed_wildcards))
config_content = '\n'.join(config_parts)
logger.debug("Generated HAProxy configuration")
@@ -3401,6 +3430,28 @@ def _config_set_matches_backup():
return True
def render_routed_list(names):
"""Pattern-file body for the default-splash routed-host ACLs.
One lowercased name per line, sorted and de-duplicated so an unchanged
fleet renders byte-identical files (create_backup() skips re-validation
when the live set equals the backup). A name containing whitespace cannot
be one pattern line (a newline would split it in two) and could not be a
working `hdr(host) -i` routing ACL either; it is dropped and logged at
ERROR, because a missing entry means the splash answers that host.
"""
out = set()
for name in names:
clean = (name or '').strip().lower()
if not clean or any(c.isspace() for c in clean):
logger.error(f"default splash: cannot list routed host {name!r}; "
"it will be answered by the splash page")
continue
out.add(clean)
body = ''.join(f'{n}\n' for n in sorted(out))
return '# Generated by haproxy_manager.generate_config() - do not edit\n' + body
def _config_backup_pairs():
"""(live, backup) pairs forming one restorable config set.
@@ -3411,6 +3462,8 @@ def _config_backup_pairs():
(HAPROXY_CONFIG_PATH, HAPROXY_BACKUP_PATH),
(BLOCKED_IPS_MAP_PATH, BLOCKED_IPS_MAP_BACKUP_PATH),
(CORAZA_SPOE_CONFIG_PATH, CORAZA_SPOE_BACKUP_PATH),
(ROUTED_HOSTS_PATH, ROUTED_HOSTS_BACKUP_PATH),
(ROUTED_WILDCARDS_PATH, ROUTED_WILDCARDS_BACKUP_PATH),
)
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Regression tests for the default splash (unrouted hosts answered pre-WAF).
Why this file exists
--------------------
Visiting a WHP server's own hostname (https://whpNN.<domain>/ on 80/443, not
the panel port), its bare IP, or any name with no site behind it used to fall
through to `default_backend default-backend` -- but only AFTER every
http-request rule had run, Coraza's send-spoe-group included. `use_backend`
does not short-circuit http-request processing. So the catch-all was WAF
inspected with the server-wide rule set, and a browser on the server hostname
also carries the control panel's cookies (cookies are not port-scoped), which
produced the WAF block page for a host that has nothing behind it to protect.
The splash answers those requests with `http-request return` BEFORE the SPOE
call. Its safety property is that it must NEVER answer a host that routes to
a site. That is held by:
- routed_hosts.list / routed_wildcards.list being written from the same
rows, in the same generate_config() call, as the routing ACLs
- the lists being port-stripped + lowercased (a superset of the raw-Host
routing ACLs)
- the lists being part of the rollback set
and it must keep ACME http-01, the blocked-IP page and the suspended page.
Running
-------
python3 scripts/test-default-splash.py
"""
import logging
import os
import re
import sqlite3
import sys
import tempfile
import unittest
MODULE_DIR = os.path.abspath(
os.environ.get('HAPROXY_MANAGER_DIR',
os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
)
os.chdir(MODULE_DIR)
sys.path.insert(0, MODULE_DIR)
_LOG_DIR = tempfile.mkdtemp(prefix='haproxy-mgr-test-logs-')
_real_file_handler = logging.FileHandler
logging.FileHandler = (
lambda filename, *a, **kw: _real_file_handler(
os.path.join(_LOG_DIR, os.path.basename(filename)), *a, **kw)
)
try:
import haproxy_manager as hm # noqa: E402
finally:
logging.FileHandler = _real_file_handler
logging.getLogger('haproxy_manager').setLevel(logging.CRITICAL)
SPLASH_FILE = os.path.join(MODULE_DIR, 'errors', 'default-splash.html')
def render_listener(**kw):
args = dict(crt_path='/etc/haproxy/certs', suspension_enabled=False,
coraza_spoe_backend='127.0.0.1:9000', default_splash=True)
args.update(kw)
return hm.template_env.get_template('hap_listener.tpl').render(**args)
def return_rules(cfg):
return [l.strip() for l in cfg.splitlines()
if l.strip().startswith('http-request return')
and 'default-splash.html' in l]
class Template(unittest.TestCase):
def test_off_renders_nothing(self):
cfg = render_listener(default_splash=False)
self.assertNotIn('default-splash', cfg)
self.assertNotIn('splash_routed_host', cfg)
def test_two_return_rules_root_200_other_404(self):
rules = return_rules(render_listener())
self.assertEqual(len(rules), 2, rules)
self.assertTrue(rules[0].startswith('http-request return status 200 '))
self.assertTrue(rules[0].endswith(' splash_root'))
self.assertTrue(rules[1].startswith('http-request return status 404 '))
self.assertTrue(rules[1].endswith(' !splash_root'))
def test_static_file_not_lf_file(self):
"""`file` (verbatim), never `lf-file`: nothing from the request may be
expanded into the page (Host echo, unique-id, ...)."""
for rule in return_rules(render_listener()):
self.assertIn(' file /haproxy/errors/default-splash.html ', rule)
self.assertNotIn('lf-file', rule)
def test_conditions_keep_acme_blocked_and_suspended_out(self):
for susp in (False, True):
for rule in return_rules(render_listener(suspension_enabled=susp)):
cond = rule.split(' if ', 1)[1].split()
self.assertIn('!splash_routed_host', cond)
self.assertIn('!splash_acme', cond)
self.assertIn('!is_blocked_ip', cond)
if susp:
self.assertIn('!is_suspended_domain', cond)
else:
self.assertNotIn('!is_suspended_domain', cond)
def test_acme_acl_is_the_challenge_prefix(self):
self.assertRegex(render_listener(),
r'acl splash_acme path_beg /\.well-known/acme-challenge/\n')
def test_routed_acls_strip_port_and_lowercase(self):
cfg = render_listener()
self.assertIn('acl splash_routed_host req.hdr(host),field(1,:),lower '
'-f /etc/haproxy/routed_hosts.list', cfg)
self.assertIn('acl splash_routed_host req.hdr(host),field(1,:),lower '
'-m end -f /etc/haproxy/routed_wildcards.list', cfg)
def test_splash_runs_before_waf_and_after_ip_block_and_suspension(self):
for susp in (False, True):
cfg = render_listener(suspension_enabled=susp)
first_return = cfg.index('file /haproxy/errors/default-splash.html')
self.assertLess(first_return, cfg.index('http-request send-spoe-group coraza'))
self.assertLess(first_return, cfg.index('filter spoe engine coraza'))
self.assertGreater(first_return, cfg.index('acl is_blocked_ip'))
self.assertGreater(first_return, cfg.index('http-request track-sc0'))
if susp:
self.assertGreater(first_return, cfg.index('acl is_suspended_domain'))
class Page(unittest.TestCase):
def setUp(self):
with open(SPLASH_FILE, 'rb') as fh:
self.raw = fh.read()
self.text = self.raw.decode('utf-8')
def test_small_enough_for_one_buffer(self):
# http-request return payload must fit tune.bufsize (16384) with headers.
self.assertLess(len(self.raw), 8192)
def test_no_links_scripts_or_external_resources(self):
low = self.text.lower()
for needle in ('href', 'src=', '<script', '<form', '<a ', 'http://', 'https://',
'8443', '@import', 'url('):
self.assertNotIn(needle, low, needle)
def test_no_version_or_template_expansions(self):
self.assertNotRegex(self.text, r'20\d\d\.\d+\.\d+')
self.assertNotIn('{{', self.text)
self.assertNotIn('%[', self.text)
class RoutedLists(unittest.TestCase):
def test_render_sorted_dedup_lower(self):
body = hm.render_routed_list(['B.example.com', 'a.example.com', 'b.example.com ', ''])
lines = [l for l in body.splitlines() if not l.startswith('#')]
self.assertEqual(lines, ['a.example.com', 'b.example.com'])
def test_name_with_whitespace_is_dropped_not_split(self):
body = hm.render_routed_list(['ok.example.com', 'bad\nevil.example.com', 'a b'])
lines = [l for l in body.splitlines() if not l.startswith('#')]
self.assertEqual(lines, ['ok.example.com'])
def test_lists_are_in_the_rollback_set(self):
pairs = dict(hm._config_backup_pairs())
self.assertIn(hm.ROUTED_HOSTS_PATH, pairs)
self.assertIn(hm.ROUTED_WILDCARDS_PATH, pairs)
class GenerateConfigWritesLists(unittest.TestCase):
"""Run the real generate_config() with paths redirected and reload stubbed."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix='splash-test-')
self.saved = {k: getattr(hm, k) for k in (
'DB_FILE', 'HAPROXY_CONFIG_PATH', 'HAPROXY_BACKUP_PATH',
'CLUSTER_SECRET_PATH', 'HAPROXY_SOCKET_PATH', 'SSL_CERTS_DIR',
'BLOCKED_IPS_MAP_PATH', 'BLOCKED_IPS_MAP_BACKUP_PATH',
'CORAZA_SPOE_CONFIG_PATH', 'CORAZA_SPOE_BACKUP_PATH',
'ROUTED_HOSTS_PATH', 'ROUTED_HOSTS_BACKUP_PATH',
'ROUTED_WILDCARDS_PATH', 'ROUTED_WILDCARDS_BACKUP_PATH',
'reload_haproxy_safely', 'create_backup')}
j = lambda n: os.path.join(self.tmp, n) # noqa: E731
hm.DB_FILE = j('db.sqlite')
hm.HAPROXY_CONFIG_PATH = j('haproxy.cfg')
hm.HAPROXY_BACKUP_PATH = j('haproxy.cfg.backup')
hm.CLUSTER_SECRET_PATH = j('cluster-secret')
hm.HAPROXY_SOCKET_PATH = j('haproxy.sock')
hm.SSL_CERTS_DIR = j('certs')
hm.BLOCKED_IPS_MAP_PATH = j('blocked_ips.map')
hm.BLOCKED_IPS_MAP_BACKUP_PATH = j('blocked_ips.map.backup')
hm.CORAZA_SPOE_CONFIG_PATH = j('coraza-spoe.cfg')
hm.CORAZA_SPOE_BACKUP_PATH = j('coraza-spoe.cfg.backup')
hm.ROUTED_HOSTS_PATH = j('routed_hosts.list')
hm.ROUTED_HOSTS_BACKUP_PATH = j('routed_hosts.list.backup')
hm.ROUTED_WILDCARDS_PATH = j('routed_wildcards.list')
hm.ROUTED_WILDCARDS_BACKUP_PATH = j('routed_wildcards.list.backup')
hm.reload_haproxy_safely = lambda *a, **kw: (True, 'stubbed')
hm.create_backup = lambda *a, **kw: (True, 'unavailable')
self.env = {k: os.environ.pop(k, None) for k in (
'HAPROXY_DEFAULT_SPLASH', 'HAPROXY_SUSPENSION_ENABLED',
'HAPROXY_CORAZA_SPOE_BACKEND')}
hm.init_db()
with sqlite3.connect(hm.DB_FILE) as conn:
cur = conn.cursor()
def add(domain, backend, wildcard=0):
cur.execute('INSERT INTO domains (domain, ssl_enabled, ssl_cert_path, '
'template_override, is_wildcard) VALUES (?, 1, NULL, NULL, ?)',
(domain, wildcard))
did = cur.lastrowid
if backend:
cur.execute('INSERT INTO backends (name, domain_id, settings) VALUES (?, ?, NULL)',
(backend, did))
cur.execute('INSERT INTO backend_servers (backend_id, server_name, '
'server_address, server_port, server_options) VALUES (?, ?, ?, ?, ?)',
(cur.lastrowid, 'web1', '10.0.0.10', 80, 'check'))
add('Site.Example.com', 'site')
add('*.wild.example.com', 'wild', wildcard=1)
add('whp01.example.net', None) # server hostname: cert-only
conn.commit()
def tearDown(self):
for k, v in self.saved.items():
setattr(hm, k, v)
for k, v in self.env.items():
os.environ.pop(k, None)
if v is not None:
os.environ[k] = v
import shutil
shutil.rmtree(self.tmp, ignore_errors=True)
def _lines(self, path):
with open(path) as fh:
return [l for l in fh.read().splitlines() if l and not l.startswith('#')]
def test_lists_written_from_routed_rows_only(self):
hm.generate_config()
self.assertEqual(self._lines(hm.ROUTED_HOSTS_PATH), ['site.example.com'])
self.assertEqual(self._lines(hm.ROUTED_WILDCARDS_PATH), ['.wild.example.com'])
with open(hm.HAPROXY_CONFIG_PATH) as fh:
cfg = fh.read()
# every exact routing ACL name is in the list (superset property)
for name in re.findall(r'acl \S+-acl hdr\(host\) -i (\S+)', cfg):
self.assertIn(name.lower(), self._lines(hm.ROUTED_HOSTS_PATH))
for base in re.findall(r'acl \S+-acl hdr_end\(host\) -i (\S+)', cfg):
self.assertIn(base.lower(), self._lines(hm.ROUTED_WILDCARDS_PATH))
self.assertIn('default-splash.html', cfg)
def test_lists_written_even_with_splash_off(self):
os.environ['HAPROXY_DEFAULT_SPLASH'] = 'off'
hm.generate_config()
self.assertTrue(os.path.exists(hm.ROUTED_HOSTS_PATH))
self.assertTrue(os.path.exists(hm.ROUTED_WILDCARDS_PATH))
with open(hm.HAPROXY_CONFIG_PATH) as fh:
self.assertNotIn('default-splash.html', fh.read())
if __name__ == '__main__':
unittest.main(verbosity=2)
+12 -1
View File
@@ -128,6 +128,8 @@ STUB_FILES = {
# `lf-file` on the Coraza deny rule; loaded at parse time. Present in the
# image (COPY errors /haproxy/errors), stubbed for anything else.
'/haproxy/errors/403-waf.html': '<html><body>blocked %[unique-id]</body></html>\n',
# `file` on the default-splash return rules; loaded at parse time too.
'/haproxy/errors/default-splash.html': '<html><body>splash</body></html>\n',
}
# (suspension_enabled, coraza_spoe_backend) combinations to render + validate.
@@ -137,6 +139,12 @@ SCENARIOS = (
'HAPROXY_SUSPENSION_ENABLED': 'true',
'HAPROXY_CORAZA_SPOE_BACKEND': '127.0.0.1:9000',
}),
# The default splash is ON unless switched off; render the off shape too.
('splash-off', {
'HAPROXY_SUSPENSION_ENABLED': 'true',
'HAPROXY_CORAZA_SPOE_BACKEND': '127.0.0.1:9000',
'HAPROXY_DEFAULT_SPLASH': 'off',
}),
)
@@ -298,7 +306,8 @@ def render(scenario_name, env_overrides, workdir):
seed_database(os.path.join(scenario_dir, 'haproxy_config.db'))
saved_env = {}
for key in ('HAPROXY_SUSPENSION_ENABLED', 'HAPROXY_CORAZA_SPOE_BACKEND'):
for key in ('HAPROXY_SUSPENSION_ENABLED', 'HAPROXY_CORAZA_SPOE_BACKEND',
'HAPROXY_DEFAULT_SPLASH'):
saved_env[key] = os.environ.pop(key, None)
os.environ.update(env_overrides)
@@ -330,6 +339,8 @@ def assert_scenario_branches(scenario_name, config_path, env_overrides):
'HAPROXY_SUSPENSION_ENABLED' in env_overrides),
'coraza': ('filter spoe engine coraza',
'HAPROXY_CORAZA_SPOE_BACKEND' in env_overrides),
'splash': ('file /haproxy/errors/default-splash.html',
env_overrides.get('HAPROXY_DEFAULT_SPLASH') != 'off'),
}
for label, (needle, should_be_present) in expected.items():
present = needle in text
+40
View File
@@ -616,6 +616,46 @@ frontend web
http-request set-path /suspended if is_suspended_domain
use_backend default-backend if is_suspended_domain
{%- endif %}
{%- if default_splash %}
# Default splash for traffic that is NOT routed to any site: the server's
# own hostname (registered cert-only, no backend), a bare IP, a stale or
# mistyped name. HAProxy answers it ITSELF with a static page and the
# request goes no further -- in particular it never reaches the Coraza
# SPOE call below.
#
# Why: `use_backend default-backend` does not short-circuit anything --
# HAProxy runs EVERY http-request rule (Coraza included) before it picks
# a backend. So the catch-all used to be WAF-inspected with the full
# server-wide rule set like customer traffic, and a browser visiting the
# server hostname also carries the control panel's cookies (cookies are
# not port-scoped: whp01:8443 cookies are sent to whp01:443). Anything
# Coraza matched there showed the WAF block page for a host that has no
# site behind it to protect. A fixed static reply has nothing to protect.
#
# "Routed" is decided from two lists generate_config() writes from the
# SAME domain rows it renders the routing ACLs from (exact names, and
# ".base" suffixes for wildcards), port stripped and lowercased. They
# are a SUPERSET of what routes -- the routing ACLs below compare the raw
# Host (port included) -- so a host that routes is never splashed; the
# worst a mismatch can do is send an unrouted request through the WAF to
# default-backend, which is the pre-splash behaviour. Both lists are part
# of the rollback set (_config_backup_pairs) so they cannot drift from
# the haproxy.cfg that is actually loaded.
#
# Kept OUT of the splash: ACME http-01 (the server hostname's own cert is
# issued through it), blocked IPs (blocked-ip page) and suspended hosts
# (suspended page). Rate limits, the IP block and the wp-* gates above
# still apply. "/" is 200 (monitors and humans), any other path 404 with
# the same page (what the old default page did for unknown paths).
# Disable with HAPROXY_DEFAULT_SPLASH=off (back to default-backend).
acl splash_routed_host req.hdr(host),field(1,:),lower -f /etc/haproxy/routed_hosts.list
acl splash_routed_host req.hdr(host),field(1,:),lower -m end -f /etc/haproxy/routed_wildcards.list
acl splash_acme path_beg /.well-known/acme-challenge/
acl splash_root path /
http-request return status 200 content-type "text/html; charset=utf-8" hdr cache-control "no-store" hdr x-content-type-options "nosniff" file /haproxy/errors/default-splash.html if !splash_routed_host !splash_acme !is_blocked_ip{% if suspension_enabled %} !is_suspended_domain{% endif %} splash_root
http-request return status 404 content-type "text/html; charset=utf-8" hdr cache-control "no-store" hdr x-content-type-options "nosniff" file /haproxy/errors/default-splash.html if !splash_routed_host !splash_acme !is_blocked_ip{% if suspension_enabled %} !is_suspended_domain{% endif %} !splash_root
{%- endif %}
# CVE-2026-109618 (GHSA-w8hw-w9fx-j79w, PHP "RCE via OOM corruption"),
# vector (a): a multipart/form-data body with no declared length is not