How it works
For each request, vigilant-heron selects a site by its Host, checks the request against limits, an access list and rules, asks the decision backend only when none of those decide, and then applies the site's policy. A request that a deterministic check blocks never reaches the backend, and no backend answer can turn a block into an allow.
Evaluation order
Each request goes through these steps, in this order. The first step that decides the request ends the evaluation.
- Limits.
limit_reqandlimit_connrun first, before the access list and before the body is read. A request over a limit gets the limit's status (503 by default) and is never forwarded. In a shadow site it is forwarded and recorded withwould_have=reject. - Host. A
Hostheader whose name has characters other than letters, digits,.,-and_, and that is not a bracketed IPv6 literal, with an optional digits-only:port, gets 400 (reasoninvalid_host) before the access list. A request without aHostheader is routed as before. - Access list. The
allowanddenyentries are checked in order by the client address. A denied client gets 403 with reasonaccess_denied. The site'sactiondoes not apply. deny_path. A path prefix of the site that matches the normalized request is a block (reasonrule, no rule id).- Block rules. The built-in rules that are on, then the top-level block rules in file order, then the site's block rules. The first match blocks.
- Skip rules. The top-level skip rules, then the site's. The first match forwards the request without calling the backend (reason
skip_rule). - Static assets. A plain GET or HEAD for a path with a static extension and no query and no body is forwarded without calling the backend (reason
skip). It is skipped only when every path reading ends in a static extension with no earlier script extension, the raw path has no;,\,%2F,%5Cor%3B, and nofail_closed_pathmatches. Astatic_locationrestricts it to some prefixes. - Decision model. The request is sent to the backend, and the policy compares the returned scores with
block_thresholdandinstruction_threshold. The outcome ismodelwhen a threshold is reached andbelow_thresholdotherwise.
Steps 2 to 5 are the deterministic checks. Every one of them runs before the backend is called, so the backend's health, its rate limits and the failure settings have no effect on them. A rule block stays a block when the backend is down, slow or saturated. Block rules are always checked before skip rules, so a skip rule never lets through a request that a block rule matches.
The normalized request is used for matching only. What reaches the upstream is the request as the client sent it, apart from the headers changed by proxy_set_header.
Path readings
The request path is read in six ways, because upstreams resolve the same bytes differently. Each reading is percent-decoded once, or resolved on the raw path, as described below, and dot segments are resolved on /:
- The decoded path.
- As 1, with each segment cut at its first
;. Tomcat, Jetty and Spring drop these path parameters. - As 1, with
\read as/. IIS and ASP.NET do this. - As 2 and 3 together.
- The raw path, with
%2Fand%5Ckept inside their segments, then percent-decoded once. Express matches the raw path this way. - As 5, with
;parameters cut and\read as/.
NUL bytes are removed from each reading. A reading that equals another is counted once. The forwarded request is never changed.
A block rule, deny_path, fail_closed_path and a block rule's path condition match when any reading matches. A skip rule's path condition must match every reading. Limits apply to every location that any reading selects. The block response and the location headers come from the location of the decoded path.
Block, redirect or forward
A blocked request gets the site's action: block (the block_status, 403 by default, with the block_page if one is set) or redirect <url> (302 to the given location). An allowed request is forwarded to the upstream. The upstream receives the whole body; the router reads only the first inspect_body bytes for the decision and streams the rest as the upstream reads it.
Failure behavior
A decision can fail in three ways, and each has its own site setting.
on_rate_limitcovers the backend's HTTP 429 and a request that finds no free concurrency slot withinqueue_timeout(backend_busy). It defaults toallow.on_client_busycovers a client that already holds itsmax_concurrency_per_clientshare of backend calls (client_busy). It defaults toblock, which answers 429 with an empty body and does not call the backend.on_decision_errorcovers timeouts, connection errors, other non-2xx statuses, and answers that cannot be parsed or are out of range. It defaults toallow.
Set each to allow or block. A saturated backend is always in the rate-limit group, so on_decision_error never applies to it.
Attackers can try a denial-of-service (DoS) attack against the decision backend: they keep many requests in flight so that the concurrency slots stay full, and the requests of other clients then fail in the rate-limit group. With on_rate_limit allow, a DoS attack of this kind makes requests skip the model. The router limits it per client with decision max_concurrency_per_client (default 8, and at most max_concurrency): a client that already has its share of calls in flight is refused at once with decision_error=client_busy, and only that client follows on_client_busy, so other clients keep being judged. This cap does not prevent a DoS attack from many addresses. For a site that prefers safety over availability, keep on_client_busy block and set on_rate_limit block, and limit each client with limit_req and limit_conn.
A client over its share can also be a legitimate user. Behind a shared address, such as an office or school NAT, or behind a proxy whose client address is not set up with real_ip_from, many users look like one client, and they can get the 429. Raise max_concurrency_per_client, or set on_client_busy allow, so that the excess skips the model while rules still apply.
Two more rules apply to failures:
- A failure on a
fail_closed_pathprefix gets the site'saction, whatever the two settings say: a block, or a redirect (302) where the site redirects. - A rule decision is not affected by any of these settings.
The decision log records the outcome in reason, one of rate_limited_allowed, rate_limited_blocked, client_busy_allowed, client_busy_blocked, decision_error_allowed, decision_error_blocked or fail_closed_path. The cause is in decision_error:
rate_limited: the backend answered HTTP 429.backend_busy: no local concurrency slot became free withinqueue_timeout.client_busy: the client already held itsmax_concurrency_per_clientshare.timeout: the backend did not answer withintimeout.connection: the HTTP exchange failed before an answer arrived.http_status: the backend answered with a non-2xx status other than 429.body: the answer's body could not be read.parse: the JSON of the answer or of the request is malformed.request: the decision request could not be built.rejected: the backend answered withsuccess: false.invalid_response: the answer lacks an answer with the expected id, or an answer has the wrong type.invalid_score: a returned score is not a probability.
site example.com {
upstream http://app:8080;
on_rate_limit allow;
on_decision_error block;
fail_closed_path /wp-login.php /wp-admin;
}With these settings, a rate-limited decision is allowed, except on the login paths, which are blocked whatever the failure. Any other decision error blocks the request.
Shadow mode
mode shadow; turns every enforcement into a record. The request is forwarded, whatever the verdict, and the access log and the optional debug header show what would have happened:
would_haveisblockorredirectfor a decision by the rules, the access list or the model, andrejectfor a limit. A failed decision also sets it when the failure settings would have blocked it:blockorredirectfor afail_closed_pathand for a rate-limited or other decision error that its setting blocks, andrejectfor a client over its share whenon_client_busyisblock. It is empty when the request would have been forwarded anyway.actionandreasonshow the decision that was made.
Some refusals happen before the request is judged, and shadow mode does not change them. They are refused in every mode, and nothing is recorded as would_have:
- A
Hostheader that is not valid gets 400 (invalid_host). - A request whose
Hostmatches no site getsdefault_status(unknown_host). A denied client with no matching site gets 403 (access_denied). With no site, there is no mode to apply. - A request line or header block over the limits gets 414 (
uri_too_long) or 400 (header_too_large), and a header block that is not complete in time gets 408 (header_timeout). - A body over
client_max_body_sizegets 413 (body_too_large). A body that is too slow gets 408 (body_timeout), and one that cannot be read gets 400 (unreadable_body).
Rules, the access list and the limits are included. In shadow mode a limit is a dry run and a denial is recorded, not enforced. Use shadow mode to measure what a rule or a threshold would do on real traffic before you enforce it.
debug_headers on; adds an x-vigilant-heron response header with action, reason and would_have. Turn it off on a public site, because it tells a client which rule or threshold blocked it.
What the backend receives
Only a sanitized copy of the request goes to the decision backend. Before it is sent:
- Credential headers are removed.
cookie,authorization,proxy-authorization,x-api-keyandx-auth-tokenare always dropped. So is any other header whose name is sensitive by the same rule as body fields, such asx-csrf-token,x-xsrf-token,x-amz-security-token,x-access-tokenandx-session-id, and each name inredact_headeris dropped too. Headers the model uses to judge a request, such ashost,user-agent,content-type,accept,referer,originandx-forwarded-for, are sent. Header names get two more rules that body fields do not. A header is dropped when one of its words isticketorauthentication, as inx-cas-ticketandx-authentication, or when its last word iskey,authkeyorsessionkey, as inx-functions-key,x-master-keyandx-authkey.x-limit-keyis dropped by the same rule.sec-websocket-keyis kept, because it is the handshake nonce and not a credential. - Form, JSON, multipart and XML bodies are scanned for sensitive fields. A field is sensitive when its name contains one of these parts, matched anywhere in the name, with case and
_,-,.ignored:password,passwd,secret,token,apikey,passphrase,passcode,credential,private_key,access_key,credit_card,card_numberandsignature. It is also sensitive when one of its words ispass,pin,otp,auth,session,cookie,jwt,pwd,card,cvv,cvcorssn. Those short words match only as whole words. A word is a run of letters and digits, split at other characters and at a lowercase to uppercase change, socard_id,userSSNandnew_pwdare sensitive, whileauthor,shipping,passenger,class_name,business_nameandpostcardare not. The value is replaced by[REDACTED]. A field named inredact_fieldis also replaced, when its name equals that name, case-insensitively. - Multipart bodies (
multipart/form-data) are split into parts. A part is redacted when itsContent-Dispositionnameis sensitive. Its value is replaced, and its boundary and part headers are sent as they were. A file part is redacted by its field name only, and its content is not scanned. A multipart body without the expected framing is scanned as text, as below. - XML and SOAP elements whose local name is sensitive have their text replaced, so
<password>and<wsse:Password Type="...">are redacted and<username>is kept. Attributes are not scanned. - Text that is none of these, including XML sent with a JSON content type, is scanned for
key=valuepairs and"key": valuepairs with the same rules. - The query string and form bodies are scanned by the same name rule. Both
∧separate parameters. A parameter whose decoded name is sensitive, or is named inredact_field, has its value replaced by[REDACTED]. The other parameters and the separators are sent exactly as the client sent them, in the same order. - Only the first
inspect_bodybytes of the body are sent, cut on a UTF-8 boundary.body_truncatedis set when the body was longer.
All of the client's data sits under untrusted, so the backend is told to read it as data and not as instructions. The event has this shape:
{"event": {
"untrusted": {"method": "POST", "path": "/login", "query": null,
"headers": [["user-agent", "…"]], "body": "user=alice&password=[REDACTED]"},
"meta": {"host": "example.com", "client_ip": "203.0.113.7", "body_truncated": false}
}}meta.host is the name of the site the request matched: the first name of the site block, or its pattern for a wildcard or regex site. It is not the client's Host, so a client cannot choose it. The client's Host is still sent, as one of the untrusted headers.
The path and query are the raw values. Normalization applies to matching only. The meta fields are set by the router, not by the client.
Redaction is a best effort on names. A secret stored under a name without one of the listed words is sent as it is. Read Security for what this means in practice, and for what the decision model can and cannot see.
Where to go next
- Rules describes the rule syntax and the access list.
- Limits describes the request-rate and connection limits.
- Decision backends describes the thresholds and the protocols.