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
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:
@@ -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
101
errors/403-waf.html
Normal 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 · %[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>
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user