Lock down a server and put a WAF in front of your app

From the first deploy, erpc worker lets SSH in only from your address and drops every other inbound connection. A [waf] table puts a filtering proxy in front of your API.
This page describes ERPC CLI v0.1.0-rc.4, a pre-release.

The firewall

A deploy sets the server's firewall before the app first starts:
  • SSH only from firewall.ssh_allow and the address the deploy comes from;
  • every other inbound connection dropped, except run.port from firewall.app_allow; for an app behind a WAF, the WAF's port in its place, and only where the WAF on the server already runs the rules [waf] now makes (below).
Each change runs under a 120-second rollback timer and is kept only once a fresh connection gets in, so a mistaken change cannot lock you out.
The allow list outlives the address it was made from, though: if your home address changes, the way back in is ERPC support for an ERPC server, or the provider's console for a server of your own. Keep a second address you can reach the server from (an office, a VPN exit) in firewall.ssh_allow before you trust a home one.
If your address changes, add every fixed address your admins use:
bash
erpc worker firewall allow --to ssh 203.0.113.10 198.51.100.7
CommandWhat it does
erpc worker firewall listShows who may reach the server: erpc.toml beside the server's table
erpc worker firewall allow --to ssh|app <addresses>Lets addresses in, and applies it. SSH is allowed from single addresses; a network is accepted only with --to app
erpc worker firewall deny --to ssh|app <addresses>Stops letting them in
erpc worker firewall syncMakes the server's firewall what erpc.toml says again

Put a WAF in front of your app

Add one table to erpc.toml:
toml
[waf]
port = 8080
erpc worker deploy then runs an HTTP reverse proxy on the server, in a systemd unit of its own (erpc-waf-<app>-<env>): an unprivileged dynamic user with no key, no secret and nothing of the worker's, held to 1 GiB of memory. It listens on waf.port, reads each request before the app does, refuses what its rules name, and forwards the rest to the app on 127.0.0.1:<run.port>.
  • The app stays on loopback. Its own port is never opened from outside. erpc worker api and the health check reach the app on loopback, past the WAF.
  • Who may connect is still the firewall's list, and the WAF's port is the one place that list may say everyone:
    sh
    erpc worker firewall allow --to app 0.0.0.0/0     # and ::/0
    With nobody listed, the WAF listens and nobody is let in, and the deploy says so.
  • The port is opened once the WAF listens on it, never before. A deploy whose WAF does not come up fails and says why, with the app still running.
  • When you change [waf], the port is not opened to anyone new until a deploy has put the new rules in place.
  • Taking [waf] out of erpc.toml and deploying removes the WAF from the server.

WAF settings

Only port is required. Every key, with its default:
toml
[waf]
port = 8080          # the public port, 1024 or above; the app keeps [run] port
methods = ["GET", "POST", "PUT", "DELETE"]
allow_paths = []     # every path
deny_paths = []
max_body_bytes = 1048576
max_header_bytes = 16384
rate = { per_second = 50, burst = 100 }
max_connections_per_address = 32
ban = { after = 100, window_secs = 60, for_secs = 600 }
block_probes = true
header_timeout_secs = 10
body_timeout_secs = 30
upstream_timeout_secs = 30
new_connections = { per_second = 50, burst = 100 }
KeyWhat it doesWhat is refused gets
methodsThe methods forwarded, in capitals (16 at most; never CONNECT)405, with Allow
allow_pathsThe paths forwarded; none listed is every path. A pattern is one path, or everything under a prefix when it ends in *: /api/* is /api and all below it. Compared with the path once percent-decoded, case-sensitive404
deny_pathsPaths refused, written the same way and checked first403
max_body_bytesThe largest request body, up to 64 MiB413
max_header_bytesThe largest request line and headers together, 1024 to 65536431
rateRequests a second from one address, and how many at once before the rate applies429, with Retry-After
max_connections_per_addressConnections one address holds at onceThe connection closed, unanswered
banAfter after blocked requests within window_secs, the address is refused for for_secs (up to 30 days)Its connections closed, unanswered
block_probesRefuses what scanners ask for (/.env, /.git, /wp-login.php and the like)403
header_timeout_secsHow long a client has to send a request's head, 1 to 120. Also how long a client that has stopped in the middle of a body, or stopped taking its answer, is waited for408 for the head; 400 for a body that stopped; the connection closed for an answer not taken
body_timeout_secsHow long a request's body may take in all, 1 to 600, not counting the time the WAF waits on the app408
upstream_timeout_secsHow long the app has to take the connection and to start answering, 1 to 600504; 502 when the app is not listening
new_connectionsThe firewall's own limit on new TCP connections a second from one address to the WAF's port, dropped by nftables before the proxy sees them. While nftables' set of addresses is full, an address not in it is not counted, so this limit does not apply to it until the set has room again; the proxy's own limits (rate, max_connections_per_address, ban) still applyThe packet dropped
rate, max_connections_per_address, ban and new_connections are turned off with false (ban = false), and a key left out is its default. erpc worker check says what the [waf] table will do, and what is wrong with it, before any deploy.

What the WAF does with no setting needed

  • A request that does not read as one unambiguous HTTP/1 request gets 400, and so does a path that is not in normal form.
  • A request to change protocol (Upgrade) gets 501.
  • Every refusal counts towards the ban, and each refusal ends its connection.
  • An address is an IPv4 address, or an IPv6 /64.
  • The WAF does not forward the headers of the client's connection to the proxy (Connection and the headers it names, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, Proxy-Connection, TE, Trailer), Expect, headers whose name has an underscore, or the client's own Forwarded, X-Real-IP and X-Forwarded-Proto. It sets X-Real-IP to the address the connection came from and adds that address to the end of X-Forwarded-For; only the last entry of X-Forwarded-For is the proxy's word.

See what the WAF does

CommandShows
erpc worker statusThe WAF's port, where it forwards, since when, and the counts since then
erpc worker firewall listWho may reach the WAF's port, and what was blocked, by rule
erpc worker logs --wafEach blocked request: the rule, the status, the client's address, the method and the first 200 bytes of the target
A forwarded request leaves no log line, and no header or body is ever logged; a blocked request's query is, as far as the 200 bytes reach. What a client sends cannot write on your terminal: the WAF writes its journal as printable ASCII.

What the WAF does not do

  • No TLS. It speaks plain HTTP/1.1 on its port.
  • No HTTP/2: it answers 400 to it.
  • No WebSocket, nor any other Upgrade: 501.
  • No look inside bodies. A body is measured, never read. The probe list is short and a noise filter, not a guarantee: the app still checks its input.
  • No authentication. Who may call the app is the app's to decide, and the firewall's list.
  • One address is one client. Clients behind one NAT, proxy or CDN share a rate and a ban; a client with many addresses has as many rates.
  • Bans and counts are forgotten when the WAF restarts.
  • It remembers at most 8192 addresses and holds at most 1024 connections at once.
  • It listens on IPv4 only, so an IPv6 entry in app_allow reaches nothing yet.
  • A path is compared as the bytes it decodes to. An app that reads paths more loosely (ignoring case, ; parameters, decoding twice) can be reached under a name deny_paths does not cover. allow_paths has no such gap: what is not listed is not forwarded.
  • A slow reader is served to its end. A client that keeps taking an answer, however slowly, is served to the end.
  • A flood can cut off a large upload. The WAF runs on one thread, so a flood can cut off a large upload with 408. An app that takes large bodies raises body_timeout_secs together with max_body_bytes.
The worker guide has every rule, including when a change to app_allow is held until the next deploy.