Actions
Once a rule matches, its action decides what happens:
| action | what it does |
|---|---|
block | stop the message, in the way that fits the protocol |
stall | answer nothing and hold the connection open, so the client waits |
replace | change the message and let it through |
log | only 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:
| protocol | on block |
|---|---|
| tcp | the connection is closed |
| http | answered with set_status (default 403), plus set_body and set_headers if given |
| ftp | the command is answered with 550; a blocked reply closes the session |
| udp | the datagram is dropped |
| dns | the query is answered with REFUSED |
| websocket | the 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:
- 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.
- 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.
- 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.
- 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:
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:
| field | on a replace rule |
|---|---|
set_body | replace the whole body (takes precedence over replace) |
set_headers | set these headers, replacing any existing value |
set_status | change the status of a response |
remove_headers | remove these headers (a blacklist) |
keep_headers | remove 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:
- 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:
- 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:
- 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:
- name: no overrides
match: { headers: { x-http-method-override: '' } }