Security
Reporting Security Issues
- Email: dev@quack.jp
- GitHub: Open a security advisory
Please do not open public GitHub issues for security vulnerabilities.
Security advisories
The router ships a few opt-in virtual patches for known flaws in products that run behind it, such as vh-cve-2026-9103. They are off by default, and an operator enables them with enable_rule on the sites that run the affected product. How the rules are chosen, rolled out and removed, and what rules cannot cover, is described in From CVE advisories to rules.
Data Disclosure
For every request that is not skipped or blocked by a rule, the router sends the request line, headers and up to inspect_body_bytes of the body to the configured decision backend. It drops Cookie, Authorization, Proxy-Authorization, X-Api-Key, X-Auth-Token, any other header with a credential-like name (such as X-Csrf-Token or X-Session-Id) and any header listed in redact_headers. It replaces the values of password-, token-, secret-, credential- and card-number-like form, JSON and query-string parameters, and of names with a whole word such as pass, pin, otp, session, card, cvv or ssn, and of the fields named in redact_field, with [REDACTED] before sending. Those short words match only as whole words, so class_name and business_name are sent. Multipart (multipart/form-data) bodies are only lexically redacted, so a sensitive field inside one can still reach the backend in some layouts. Everything else, which can include personal data typed into forms, reaches the backend. Point the router at a backend you may disclose that data to, or run the decision model yourself.
With backend = "clef", the same sanitized event is sent to Cloudflare Workers AI (api.cloudflare.com), a third party, as the state of a Clef request. The bearer token is sent in the Authorization header to that endpoint only. Use an account whose Workers AI data handling you have accepted, and keep the token in the environment, not in the config file. The router never logs the token.
Access logs contain the client address, the method, the path, the status and the decision fields, but never a request body, a query string or a credential header. A $http_* variable that names a credential or a redacted header is rejected when the configuration loads. Every logged value is escaped, so a request cannot inject a line break or a control character into the log. The json format also escapes the C1 controls and U+2028 and U+2029, and the main format quotes its free-text values.
With backend = "jev", the same sanitized event is sent to TypeSafe (api.typesafe.ai), a third party, in the same System One request shape. The bearer token goes only to that endpoint, and the router never logs it. The router does not enable TypeSafe's long-context mode, so events are sent in the standard form.
Slow clients and memory
Each limit below is an nginx directive of the same name. The defaults are nginx's, and the table in the README lists them all.
- A client that sends its header a byte at a time is cut off by
client_header_timeout. A client that holds an idle connection is closed bykeepalive_timeout, andkeepalive_requestscloses a connection after a fixed number of requests. - A client that stops reading the response is cut off by
send_timeout, so a stalled response cannot hold a connection open forever. A client that sends its body slowly is cut off byclient_body_timeout. large_client_header_buffersbounds the request line and the header section before they are parsed: a long request line gets 414, and a long field or a header section over its total gets 400.client_max_body_size(1m by default) refuses a body larger than the limit with 413. AContent-Lengthover the limit is refused before any body byte is read; a chunked body is cut off once it grows past the limit.- A request body is not held in memory. The first
inspect_bodybytes (8k by default) are read for the decision, and the rest is streamed to the upstream. Memory per request is therefore bounded byinspect_body, one read frame, and hyper's buffers, not by the body size. Before this, up to 10 MB of each body was buffered. - Each connection still costs hyper's read buffer, which is about 408 KiB at most, and the number of connections is capped by
max_connections. The timers above limit how long a connection can hold that memory, not how much of it is used. Request rate is not limited, and an upstream or backend that is slow holds its request for at most the matching timer.
The header limits check the bytes of each request as they are read. A request pipelined behind another on the same connection is not checked by them.
A response sent before the request body is read (413, 403 for a rule block, 408 or 400) is followed by a lingering close, as nginx does. The router reads and discards the client's remaining bytes, in a 4 KiB buffer, so a client that is still sending receives the response instead of a reset. Lingering is bounded: it ends when the client closes, after lingering_timeout without data, or after lingering_time in total, so it cannot hold a connection longer than lingering_time. The total is 30s by default and the silence is 5s. An attacker can still keep one connection open for lingering_time after each rejected request, so max_connections and the memory per connection still bound the load. lingering_close off removes this cost and gives up the orderly response to such clients.
Access list and rules
The access list, the deny_path entries and the rules are decided before the decision model, in the order the README gives. They never send data anywhere.
- A model answer cannot turn an access-list refusal, a
deny_pathmatch or a block rule into an allow. A skip rule cannot let through a request that a block rule matches, because every block rule is checked first. - The access list uses the client address chosen by the realip settings. That address is believed only from a peer listed in
real_ip_from, so a client that is not a trusted proxy cannot pick its own address with a header. - Rules match the normalized request: the path is read in six ways (decoded, with
;parameters cut, with\as/, and the raw path with encoded separators kept), the query and bodies are decoded at most twice, and NUL bytes are removed. A third level of encoding is not decoded, so an input encoded three times is not seen as the attack it encodes. The request is forwarded unchanged. A block matches when any reading matches, so the reading an upstream uses cannot escape adeny_path. A static skip needs every reading to be static, so a path such as/index.php/x.cssor/x;.cssis always inspected. - A body rule sees only the first
inspect_bodybytes. Anything later in the body is not matched, and a multipart body is not parsed. - Regexes use the
regexcrate, which matches in time linear in the input and never backtracks, so a pattern cannot be made to take exponential time by a crafted request. Each regex is limited to 512 bytes of source and 1 MiB of compiled program, and a configuration holds at most 1000 rules.
The built-in rules are signatures, and they are intentionally narrow. They do not cover:
- SQL injection in form bodies or JSON, except tautologies with a comment terminator, stacked destructive statements and time-based functions, which are matched anywhere in the inspected input.
UNION SELECTis matched in query strings only. - SQL keywords split by comments or inserted characters (
UN/**/ION), other SQL dialects, and stored or second-order injection, which no single request shows. - XSS in bodies, headers and paths, and the script or
javascript:forms in bodies. Only query strings carry<script>,javascript:and inline event handlers, because those forms also occur in code pastes and posts about HTML. DOM-based XSS never reaches the server. - Deserialization gadgets, template injection beyond the probes listed, and command injection that has no shell separator in a query string.
- Payloads with any encoding beyond two levels, or in another transport encoding such as base64 inside a parameter.
- Credential stuffing, bursts and anything that only shows across requests.
Each rule is an opinion about attack syntax, not a proof. Use a rule-based WAF in front of the router as well, and disable a built-in rule with disable_rule when it blocks traffic you need.
Limits of the decision model
The model judges text written by the attacker. Payloads can try to steer it, and it can miss attacks or flag legitimate requests. The router limits what a steered model can do:
- Attacker-controlled data is sent under a separate
untrustedfield. - Rule blocks (
deny_pathand block rules) are decided before the model is called, so a model answer can never turn a rule block into an allow. - Requests whose content addresses an AI model are a block signal of their own (
instruction_threshold).
Keep a rule-based WAF in front of the router, start each site in shadow mode, and switch to enforce only after measuring missed attacks and false positives on real traffic.
Password guessing and request floods
The decision model judges one request at a time, so it cannot tell that the same client has tried a hundred passwords. Throttling that is the job of limit_req on the path, for example on /wp-login.php (see README, "Limits"). A limit is a rate, not a verdict: the model's scores do not change it.
Zones live in the router process. When a zone has no free state, the request is treated as a rejection: it gets the configured status (503 unless limit_req_status or limit_conn_status says otherwise), and in dry run it goes on. The error log marks such events with limit_error=zone_exhausted, so a flood of distinct keys is visible to operators. Size the zones for the number of distinct keys ($client_ip) you expect; a larger zone holds more states, at 128 bytes (limit_req) or 64 bytes (limit_conn) per state.
Upgraded connections and 444
proxy_websocket on; tunnels an HTTP Upgrade after its handshake is screened. The handshake goes through the same checks as any request, and a blocked handshake gets no upgrade. After a 101, the frames are not inspected: the router copies the bytes both ways without parsing them. A payload that attacks the upstream after the handshake passes unseen, and only the handshake is judged. A tunnel closes when either side closes, or when neither side moves a byte for proxy_read_timeout. It holds its limit_conn permits while open.
block_status 444; closes the connection without writing a response, as nginx does. A client that sent a body may then see a reset instead of a status. The request is still logged with status 444.
Reload
SIGHUP validates the whole configuration before it applies it. A file that fails validation is rejected: the error is logged with its file and line, and the configuration that was running keeps serving. Requests in flight during a reload finish with the configuration they started with. The listen address and the connection-level settings do not change until a restart, and a reload that changes them logs a warning. The error log follows the reloaded file: its level and destination apply at once. A destination that cannot be opened fails the reload, and the failure is logged to the destination still in use.
Shutdown
SIGINT and SIGTERM stop the router. Requests in flight finish, and open WebSocket tunnels stay open until one side closes them or they go idle for proxy_read_timeout. Without worker_shutdown_timeout, a shutdown waits for them indefinitely, so one busy tunnel can keep the process from exiting. With worker_shutdown_timeout, every connection and tunnel still open is closed when the time expires.
Failure behavior
When the decision backend cannot judge a request, the outcome depends on the failure and on the site settings:
- A decision fails in one of two groups.
rate_limitedcovers HTTP 429 and saturation: no concurrency slot obtained withindecision.queue_timeout_ms(backend_busy). Saturation is always this group, so it followson_rate_limitand neveron_decision_error.errorcovers timeouts (also after a slot was obtained), connection failures, other non-2xx statuses,success: false, and malformed or out-of-range answers. - A client that already holds its
max_concurrency_per_clientshare is in a group of its own,client_busy. It followson_client_busy, which defaults to"block": the client gets 429 with an empty body, and the backend is not called. on_rate_limitandon_decision_errorset the outcome for each group:"block"(the default) applies the site'saction,"allow"forwards the request.fail_closed_pathalways blocks a failure on a matching path, whatever the group or setting.- Deterministic rules (the access list,
deny_pathand block rules) are decided before the backend is called. The failure settings do not affect them, so a rule block stays a block when the backend rate-limits, errors, is unreachable or is saturated. Shadow mode still forwards every request, rules included.
The defaults allow requests when the backend fails or is saturated, so a provider outage does not block legitimate traffic. A client over its share is refused by default, because that is the case the per-client cap exists for. The cost is that an attacker who makes the backend unavailable on purpose can get requests past the model, and a site that keeps on_rate_limit "allow" or on_decision_error "allow" accepts that. Keep fail_closed_path on sensitive paths, which block whatever the setting.
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, which is in its own group, so only that client follows on_client_busy (block by default), and 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", set on_rate_limit "block", and limit each client with limit_req and limit_conn.
The per-client cap also covers a single client that keeps many requests in flight on its own. A distributed DoS attack from many addresses is not prevented by it.