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:
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>
|
||||
Reference in New Issue
Block a user