Skip to content

Actions ​

Once a rule matches, its action decides what happens:

actionwhat it does
blockstop the message, in the way that fits the protocol
stallanswer nothing and hold the connection open, so the client waits
replacechange the message and let it through
logonly record that the rule matched

Without an action, a rule replaces if it has replace, remove_headers or keep_headers, and blocks otherwise. A replace rule that would not change anything is an error.

block ​

What blocking does depends on the protocol:

protocolon block
tcpthe connection is closed
httpanswered with set_status (default 403), plus set_body and set_headers if given
ftpthe command is answered with 550; a blocked reply closes the session
udpthe datagram is dropped
dnsthe query is answered with REFUSED
websocketthe connection is closed

On http, the answer to a blocked request can be shaped to look like the service's own errors. Attackers have the same source code, so this does not hide that the route exists, but an answer that looks like the service's usual "not found" says less about what stopped them than a bare 403:

yaml
- name: debug route
  match: { path: '^/debug' }
  set_status: 404
  set_body: "Not Found\n"
  set_headers: { Content-Type: text/plain }

A blocked response (dir: out) is replaced by that answer, as long as its head has not been sent yet. See how http bodies are handled for when that is.

stall ​

action: stall answers nothing at all. The connection is held open for timeouts.stall (60 seconds by default) and then dropped, with no response, no error and no close in between. An exploit waiting on it is stuck for the whole time instead of getting a quick refusal and moving on to its next attempt, and it learns nothing about why.

yaml
- name: tarpit the traversal
  action: stall
  match: { path: '\.\./|%2e%2e' }

It works on tcp, http and ftp ports, on requests and responses alike. Udp and dns have no connection to hold, so a stall rule on those ports is refused when the rules load; use block.

A stall rule cannot also change the message, since it never sends one: set_status, set_body, set_headers and replace are refused alongside it.

It is worse than a block if it hits the checker

A blocked checker fails one request and moves on. A stalled one is held for the whole timeouts.stall, which can cost the rest of the tick and the next. Run a new stall rule as action: log first, and keep it on patterns that normal use of the service cannot produce.

It cannot be used to flood vulnboxd ​

Holding connections open is what a flood wants, so an attacker who works out which requests stall could otherwise pile them up until vulnboxd runs out of sockets and the checker cannot get in.

Each port therefore holds at most limits.stalled connections (256 by default) at a time. Past that a stall rule blocks instead, which is what it would have done without the feature, so a flood is no worse than it would have been. vulnboxd_connections_stalled says how many are held; sitting at the limit is the sign that the tarpit is full.

Two more things keep the cost down: the wait is bounded by timeouts.stall, and a stalled http request lets go of its connection to the service first, so the service is not held up by it either.

log ​

action: log changes nothing. The match is logged at info with a preview of what matched, and counted in vulnboxd_rule_hits_total with action="matched". Use it to try a rule before it blocks, and to keep an eye on things that are not worth blocking.

yaml
- name: watch logins
  action: log
  match: { method: POST, path: '^/login' }

replace ​

replace maps regexes to replacements, applied to the payload: the stream on tcp, the body on http, the line on ftp, the datagram on udp.

yaml
- name: redact flags on the debug page
  dir: out
  match: { path: '^/debug' }
  replace:
    '{flag}': '[redacted]'
    'password=\S+': 'password=***'

In the replacement, $1 (or ${1}) inserts a capture group, ${name} a named group, and $$ a literal dollar sign.

Instead of text, a replacement can encrypt or decrypt what it matched:

yaml
replace: { '{flag}': { encrypt: flags } }

Replacements happen as the data streams by. A replace rule holds back only as many bytes as could still turn out to be part of a match. A match spanning more than 64 KiB is left alone and logged.

On http, a body that was rewritten while streaming goes out chunked, since its length is no longer known. Requests small enough to buffer are rewritten whole, so services that cannot read chunked requests keep working.

Changing http messages ​

On http ports, a replace rule can also change the head of a request or response:

fieldon a replace rule
set_bodyreplace the whole body (takes precedence over replace)
set_headersset these headers, replacing any existing value
set_statuschange the status of a response
remove_headersremove these headers (a blacklist)
keep_headersremove every header not listed (a whitelist)

set_body, set_headers and set_status only change a message when the rule's action is replace; on a blocking rule they describe the answer instead. So to change a message with only those, say so:

yaml
- name: no stack traces
  dir: out
  match: { status: 500 }
  action: replace
  set_body: "internal error\n"
  set_headers: { Content-Type: text/plain }

A crashing request still gets its 500, but not a traceback with file paths, configuration or the secret key the service signs its sessions with.

Header lists ​

remove_headers and keep_headers take header names, compared case-insensitively. A trailing * matches any suffix:

yaml
- name: no spoofed client headers
  remove_headers: [X-Forwarded-For, X-Real-IP, 'X-Original-*']

- name: no debug headers
  dir: out
  remove_headers: ['X-Debug-*']

The first keeps a service from believing a request came from localhost because the request says so.

keep_headers is the stricter form: only the listed headers reach the service. Build the list from what the service's code reads, not from what you saw in traffic, or a header the checker sends only now and then will break it:

yaml
- name: request header whitelist
  keep_headers: [Content-Type, Cookie, Authorization, Accept]

keep_headers never removes Host, Content-Length or Transfer-Encoding, which the request cannot do without. To reject messages that carry a header instead of stripping it, block on it:

yaml
- name: no overrides
  match: { headers: { x-http-method-override: '' } }