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:
1 parent
1ee5760c1b
commit
8787e0d99e
9 files changed
+471
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user