You are reading the docs for rws 18.23 The latest release is 18.24. Read this page for the latest release.

Web application firewall

rws's built-in WAF — SQL injection, XSS, path traversal, command injection, Log4Shell, SSRF and scanner detection with anomaly scoring, detect or block, exclusions and custom rules.

Since 18.22.0 rws checks requests itself before they reach your app. No rule language to learn and no external engine: a table in the config file.

[middleware.waf]              # every route (and the built-in server); or [route.middleware.waf] per route
# mode     = "block"          # block (default) | detect (log only) | off
# paranoia = 1                # 1–3: higher adds stricter, noisier rules
# threshold = 5               # anomaly score that refuses a request (5 = one critical match)
# inspect_body_bytes = 65536  # how much of a body is checked (0: bodies aren't)
# max_args = 255              # more arguments than this is a match
exclude = ["942100@/api/search", "941*@/cms/"]   # rule[@path prefix] off where it misfires

[[middleware.waf.rule]]       # your own rules
id     = 1001
field  = "path"               # path | query | args | arg:<name> | header:<name> | method | ip | body
prefix = ["/wp-login.php", "/xmlrpc.php"]   # or contains = […], equals = […], cidr = […] for ip
action = "deny"               # deny | allow (skip the WAF) | log | score (+ score = 5)

What it checks

The path, query arguments, form fields, JSON string values, the non-file fields of multipart forms (and uploaded file names), cookie values, the User-Agent, and every header for Log4Shell. Values are decoded first — percent-encoding (also double), HTML entities, \x / \u escapes, SQL /* comments */ — so encoded attacks are seen as what they decode to.

Rules Detects Paranoia
942100–942160 SQL injection: boolean logic or a comment after a quote (' or '1'='1, admin'--), UNION SELECT, stacked queries (; drop table), sleep() / benchmark() / waitfor delay, information_schema / load_file / into outfile, or 1=1 1
942200, 942300 SELECT … FROM in a value; -- / # comments 2, 3
941100–941140 XSS: <script>, event handlers in tags or after a broken-out attribute (<img onerror=…>, " onmouseover="…), javascript: / vbscript: / data:text/html URLs, <iframe> / <object> / <embed> / <base> / <meta>, CSS expression() / srcdoc 1
941300 any HTML tag in a value 3
930100, 930110 path traversal (../../), sensitive files (/etc/passwd, win.ini, .git/, .env, wp-config.php, id_rsa) 1
931100 inclusion wrappers: php://, phar://, expect://, file://, gopher://, data:// 1
932100, 932110 command injection: a shell command after ;, |, &&, a backtick or $(; $IFS evasion 1
932200 shell substitution syntax 2
934100 SSRF to cloud metadata (169.254.169.254, metadata.google.internal, …) 1
944100 Log4Shell JNDI lookups, obfuscated ones (${${lower:j}ndi:…}) included 1
913100 security scanners by User-Agent (sqlmap, nikto, nuclei, wpscan, …) 1
920100, 920200 too many arguments; a value over 8 KiB 1, 2

The ids follow the OWASP Core Rule Set's families so they're familiar; the rules themselves are rws's own and much smaller than the CRS. Paranoia 1 is tuned so ordinary text (O'Reilly, salt & pepper; to taste, Union Station, x < y and y > z) passes — the test suite carries an attack and a benign corpus.

What happens on a match

Each match adds its score (5 for paranoia-1 rules). At threshold the request is refused with 403 and an RFC 9457 problem+json body naming the rules; nothing reaches the app. Every match is logged — [waf] blocked 203.0.113.7 GET /search?q=… score=10 942100@arg:q 942110@arg:q — and counted: rws_waf_requests_total{action="blocked"|"detected"} and rws_waf_rule_matches_total{rule="…"} on /metrics.

Start new sites with mode = "detect" for a few days, read the log, add exclusions for what misfires, then switch to block. A custom allow rule (an office network, a trusted health checker) skips the WAF; a deny rule refuses whatever the mode.

Placement and cost

Per route the WAF runs after the IP filter and rate limit and before auth, so login pages are covered. A request without matches costs a few microseconds in a release build; bodies are read whole up to inspect_body_bytes instead of streamed. Routes with a WAF (like any middleware) use the blocking path rather than the non-blocking proxy forwarder. It's a layer, not a substitute for parameterized queries and output escaping in the app.

From Rust: rust_web_server::waf::WafLayer::new(WafConfig { .. }) on any app with .wrap(…).

Edit this page on GitHub