diff --git a/CLAUDE.md b/CLAUDE.md index 1c11b9d..50b1984 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//` (leaving archive/ behind makes certbot refuse the name forever), never deleted; the bundle's own `--cert-name` lineage is `live/` 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) diff --git a/Dockerfile b/Dockerfile index 8d84958..dce0ce3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 3ccea2d..eae6db6 100644 --- a/README.md +++ b/README.md @@ -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:///` 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:/// # 200, splash +curl -sk -o /dev/null -w '%{http_code}\n' https:/// # 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 diff --git a/VERSION b/VERSION index b3583ef..92848b9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026.10.7 +2026.11.1 diff --git a/errors/default-splash.html b/errors/default-splash.html new file mode 100644 index 0000000..4d8654d --- /dev/null +++ b/errors/default-splash.html @@ -0,0 +1,34 @@ + + + + + + + +Web Hosting Platform + + + +
+

Web Hosting Platform

+

This server is operated by a web hosting platform. There is no website configured at this address.

+
+ + diff --git a/haproxy_manager.py b/haproxy_manager.py index 2492d63..88bc8bd 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -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), ) diff --git a/scripts/test-default-splash.py b/scripts/test-default-splash.py new file mode 100644 index 0000000..8716282 --- /dev/null +++ b/scripts/test-default-splash.py @@ -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./ 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=', 'blocked %[unique-id]\n', + # `file` on the default-splash return rules; loaded at parse time too. + '/haproxy/errors/default-splash.html': 'splash\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 diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index a916303..34ad209 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -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