waf-block: render a real HTML page on Coraza-denied requests
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 1m55s

Previously a Coraza block returned an empty 403 with only the
`waf-block: request` header — a legitimate site owner caught in a
false-positive had no idea what happened or how to get help.

Now:
- hap_header.tpl: every request gets a unique-id (uuid()) and that ID
  is injected back into the request as X-Request-Reference for the
  backend, so upstream Apache/PHP logs can correlate too.
- hap_listener.tpl: on a request-phase Coraza deny we use
  `http-request return` with `lf-file` instead of `http-request deny`,
  so HAProxy renders the new errors/403-waf.html page with the
  request reference substituted in. The page tells the visitor a
  request was blocked, displays the reference, and points site owners
  to https://secure.anhonesthost.com/submitticket.php to open a ticket
  rather than exposing a public email address (avoids giving
  attackers a flood target).
- The waf-block header and x-request-reference header are still set
  on the response so curl / monitoring clients can pick them up
  without rendering HTML.
- Response-phase deny stays as the bare 403 — outbound blocks are
  rare in our config and an HTML body could land mid-stream.

Errorfile lives at /haproxy/errors/403-waf.html (NOT under
/etc/haproxy/, because that path is a named volume in deployed
containers and would shadow baked-in files on existing deployments).

Support workflow: visitor quotes the reference → support greps
/var/log/haproxy.log for the uuid → gets timestamp + client IP +
Host + URI → greps /var/log/coraza/audit.log for the matching
transaction → reads the rule_id that fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 05:41:16 -07:00
parent 220b28f0c4
commit d931ab0dbc
4 changed files with 128 additions and 2 deletions

View File

@@ -17,6 +17,11 @@ COPY haproxy_manager.py /haproxy/
COPY scripts /haproxy/scripts COPY scripts /haproxy/scripts
COPY trusted_ips.list /etc/haproxy/trusted_ips.list COPY trusted_ips.list /etc/haproxy/trusted_ips.list
COPY trusted_ips.map /etc/haproxy/trusted_ips.map COPY trusted_ips.map /etc/haproxy/trusted_ips.map
# /etc/haproxy is a named volume in deployed containers, so baked-in files
# under that path get shadowed by the volume on existing deployments.
# Place errorfiles outside the volumed path; the HAProxy config references
# them by absolute path.
COPY errors /haproxy/errors
RUN chmod +x /haproxy/scripts/* RUN chmod +x /haproxy/scripts/*
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
# Create log directories # Create log directories

101
errors/403-waf.html Normal file
View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<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>Request blocked &middot; %[req.hdr(host)]</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #1f2937;
background: linear-gradient(135deg, #f9fafb 0%, #eef2f7 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
line-height: 1.5;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 12px 32px rgba(31,41,55,0.08);
max-width: 560px;
width: 100%;
padding: 36px 40px;
}
.badge {
display: inline-block;
background: #fef3c7;
color: #92400e;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 16px;
}
h1 { font-size: 22px; margin: 0 0 12px; color: #111827; }
p { margin: 0 0 14px; color: #374151; }
.ref {
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 12px 14px;
margin: 20px 0;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 13px;
color: #111827;
word-break: break-all;
}
.ref-label {
display: block;
color: #6b7280;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 4px;
font-family: inherit;
}
.owner {
border-top: 1px solid #e5e7eb;
margin-top: 24px;
padding-top: 20px;
color: #4b5563;
font-size: 14px;
}
.owner h2 { font-size: 14px; font-weight: 600; color: #111827; margin: 0 0 8px; }
a {
color: #1d4ed8;
text-decoration: none;
border-bottom: 1px solid transparent;
}
a:hover, a:focus { border-bottom-color: #1d4ed8; }
.small { font-size: 12px; color: #6b7280; margin-top: 16px; }
</style>
</head>
<body>
<main class="card" role="main">
<span class="badge">Access blocked</span>
<h1>Your request was blocked by our security filter</h1>
<p>The request to <strong>%[req.hdr(host)]</strong> looked suspicious to our web application firewall and was not delivered to the site.</p>
<p>This is automated. No one has reviewed the request yet.</p>
<div class="ref">
<span class="ref-label">Request reference</span>
%[unique-id]
</div>
<div class="owner">
<h2>Site owner?</h2>
<p>If you operate this site and believe this block is incorrect, please <a href="https://secure.anhonesthost.com/submitticket.php" rel="noopener">open a support ticket</a> and include the request reference above. Our team can look up exactly which rule fired and adjust it if it's a false positive.</p>
</div>
<p class="small">Reference IDs expire from our active logs after 14 days, so please open a ticket promptly if you'd like this investigated.</p>
</main>
</body>
</html>

View File

@@ -74,3 +74,14 @@ defaults
timeout tarpit 10s # Tarpit delay for low-level scanners (before silent-drop) timeout tarpit 10s # Tarpit delay for low-level scanners (before silent-drop)
maxconn 3000 maxconn 3000
# Per-request unique reference, used:
# - in the log line (httplog includes %ID)
# - echoed to clients in the X-Request-Reference response header on
# WAF blocks so a customer can quote it when opening a support ticket
# - embedded in /etc/haproxy/errors/403-waf.html so a blocked visitor
# sees it on the rendered 403 page
# Support correlates ref → /var/log/haproxy.log line → timestamp+client+host
# → /var/log/coraza/audit.log entry → rule_id.
unique-id-format %[uuid()]
unique-id-header X-Request-Reference

View File

@@ -85,8 +85,17 @@ frontend web
# disruptive action fires (depends on SecRuleEngine mode + per-rule # disruptive action fires (depends on SecRuleEngine mode + per-rule
# ctl:ruleEngine overrides). Without these rules, Coraza would inspect # ctl:ruleEngine overrides). Without these rules, Coraza would inspect
# but never block. # but never block.
http-request deny deny_status 403 hdr waf-block "request" if { var(txn.coraza.action) -m str deny } #
http-response deny deny_status 403 hdr waf-block "response" if { var(txn.coraza.action) -m str deny } # On request-phase deny we return a rendered HTML page that surfaces the
# request reference (the unique-id) so a customer who's been blocked
# incorrectly can open a support ticket and quote it. lf-file expands
# log-format expressions inside the file at response time, so
# %[unique-id] / %[req.hdr(host)] / etc. get substituted live.
# Response-phase deny stays as a bare 403 — outbound blocks are rare in
# our config (Coraza response inspection is disabled by default) and
# an HTML body on a 403 generated mid-response could land mid-stream.
http-request return status 403 content-type "text/html; charset=utf-8" hdr waf-block "request" hdr x-request-reference "%[unique-id]" lf-file /haproxy/errors/403-waf.html if { var(txn.coraza.action) -m str deny }
http-response deny deny_status 403 hdr waf-block "response" hdr x-request-reference "%[unique-id]" if { var(txn.coraza.action) -m str deny }
http-request silent-drop if { var(txn.coraza.action) -m str drop } http-request silent-drop if { var(txn.coraza.action) -m str drop }
http-response silent-drop if { var(txn.coraza.action) -m str drop } http-response silent-drop if { var(txn.coraza.action) -m str drop }
http-request redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect } http-request redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect }