Skip to content

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.

  1. Limits. limit_req and limit_conn run 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 with would_have=reject.
  2. Host. A Host header 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 (reason invalid_host) before the access list. A request without a Host header is routed as before.
  3. Access list. The allow and deny entries are checked in order by the client address. A denied client gets 403 with reason access_denied. The site's action does not apply.
  4. deny_path. A path prefix of the site that matches the normalized request is a block (reason rule, no rule id).
  5. 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.
  6. Skip rules. The top-level skip rules, then the site's. The first match forwards the request without calling the backend (reason skip_rule).
  7. 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, %5C or %3B, and no fail_closed_path matches. A static_location restricts it to some prefixes.
  8. Decision model. The request is sent to the backend, and the policy compares the returned scores with block_threshold and instruction_threshold. The outcome is model when a threshold is reached and below_threshold otherwise.

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 /:

  1. The decoded path.
  2. As 1, with each segment cut at its first ;. Tomcat, Jetty and Spring drop these path parameters.
  3. As 1, with \ read as /. IIS and ASP.NET do this.
  4. As 2 and 3 together.
  5. The raw path, with %2F and %5C kept inside their segments, then percent-decoded once. Express matches the raw path this way.
  6. 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_limit covers the backend's HTTP 429 and a request that finds no free concurrency slot within queue_timeout (backend_busy). It defaults to allow.
  • on_client_busy covers a client that already holds its max_concurrency_per_client share of backend calls (client_busy). It defaults to block, which answers 429 with an empty body and does not call the backend.
  • on_decision_error covers timeouts, connection errors, other non-2xx statuses, and answers that cannot be parsed or are out of range. It defaults to allow.

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_path prefix gets the site's action, 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 within queue_timeout.
  • client_busy: the client already held its max_concurrency_per_client share.
  • timeout: the backend did not answer within timeout.
  • 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 with success: 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.
nginx
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_have is block or redirect for a decision by the rules, the access list or the model, and reject for a limit. A failed decision also sets it when the failure settings would have blocked it: block or redirect for a fail_closed_path and for a rate-limited or other decision error that its setting blocks, and reject for a client over its share when on_client_busy is block. It is empty when the request would have been forwarded anyway.
  • action and reason show 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 Host header that is not valid gets 400 (invalid_host).
  • A request whose Host matches no site gets default_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_size gets 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-key and x-auth-token are always dropped. So is any other header whose name is sensitive by the same rule as body fields, such as x-csrf-token, x-xsrf-token, x-amz-security-token, x-access-token and x-session-id, and each name in redact_header is dropped too. Headers the model uses to judge a request, such as host, user-agent, content-type, accept, referer, origin and x-forwarded-for, are sent. Header names get two more rules that body fields do not. A header is dropped when one of its words is ticket or authentication, as in x-cas-ticket and x-authentication, or when its last word is key, authkey or sessionkey, as in x-functions-key, x-master-key and x-authkey. x-limit-key is dropped by the same rule. sec-websocket-key is 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_number and signature. It is also sensitive when one of its words is pass, pin, otp, auth, session, cookie, jwt, pwd, card, cvv, cvc or ssn. 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, so card_id, userSSN and new_pwd are sensitive, while author, shipping, passenger, class_name, business_name and postcard are not. The value is replaced by [REDACTED]. A field named in redact_field is also replaced, when its name equals that name, case-insensitively.
  • Multipart bodies (multipart/form-data) are split into parts. A part is redacted when its Content-Disposition name is 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=value pairs and "key": value pairs with the same rules.
  • The query string and form bodies are scanned by the same name rule. Both & and ; separate parameters. A parameter whose decoded name is sensitive, or is named in redact_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_body bytes of the body are sent, cut on a UTF-8 boundary. body_truncated is 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:

json
{"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.

Released under the MIT License.