A self-hosted Layer 7 web application firewall written in Go. You run it on your own VPS, in front of nginx, Caddy, or Traefik, and it soaks up floods, bots, and the usual injection noise before any of it reaches your app.
Docs and benchmarks: https://1rhino2.github.io/RhinoWAF/
RhinoWAF sits between the internet and your backend and handles the Layer 7 side of things:
- Rate limiting and DDoS mitigation (burst detection, slowloris, reputation)
- Per-IP rules with geo and ASN blocking
- Challenge pages (JavaScript, proof-of-work, hCaptcha, Cloudflare Turnstile)
- Browser fingerprinting to catch bot networks sharing one browser
- CSRF token validation
- HTTP request smuggling detection
- A detection engine with anomaly scoring: SQLi (real tokenizer), XSS (HTML-aware), RCE, path traversal, SSRF, SSTI, NoSQL, XXE, log4shell, scanner fingerprints, and response leakage. Explains every block.
- Persistent bans and signed challenge passes (survive a restart)
- Exemptions, per-path body limits, and honeypot traps
- WebSocket connection and message limits
- Reverse proxy with multi-vhost routing
Requests run through a rule engine, not a list of banned substrings. Each rule decodes the input it cares about (url, html, js, base64, ...) and matches it with a real detector: a SQL tokenizer that folds to a fingerprint, an HTML tokenizer for XSS, IP-form parsing for SSRF, and so on. Matches add to an anomaly score kept per argument, so one long legitimate value cannot pile up into a block while an attack spread across fields still adds up. When the score crosses the threshold the request is blocked and the reason is logged:
[ENGINE] BLOCK 203.0.113.9 POST /login score=7/5 pl=1 top=args:user rules=942100,942190 req=ab12
grant writing tips, drop off the kids, O'Brien, url(image.png) and
<p>hello <b>world</b></p> all pass; ' or 1=1-- and <img src=x onerror=>
do not. The rule format, operators, scoring, paranoia levels, and how to write
an exclusion are in docs/rules.md. Start a new site in
detect mode to watch before you block.
Good fit for a website, panel, store, or API on HTTP/HTTPS (ports 80/443).
It is not a Minecraft proxy. If you need to protect a Java game port, use TCPShield or similar for the game server and put RhinoWAF in front of the website and API.
Version 2.0.x, active development. It is solid enough to self-host, but treat it like software you test in staging first, not a drop-in swap for Cloudflare or ModSecurity. Run it against your real Host headers, APIs, and webhooks before you point production traffic at it. No warranty, you own the downtime risk.
git clone https://github.com/1rhino2/RhinoWAF.git
cd RhinoWAF
go build -o rhinowaf ./cmd/rhinowaf
./rhinowafThat starts the WAF on :8080, proxying to a backend at http://localhost:9000.
Point it somewhere else without rebuilding:
./rhinowaf -listen :8443 -backend http://127.0.0.1:3000Check the version:
./rhinowaf -version
# RhinoWAF 1.0.6 (built ...)You can also grab a prebuilt binary from the releases page instead of building.
Everything lives under config/. You no longer edit main.go and rebuild for
the common knobs, the files and flags below cover it.
| File | Controls |
|---|---|
config/features.json |
Everything: listen address, backend, trusted proxies, the detection engine, challenge, fingerprinting, websocket, CSRF, exemptions, body limits, honeypot, auto-ban, state, logging |
config/ip_rules.json |
Per-IP rules, geo rules, global rate limits, proxy/Tor/hosting blocking |
config/geoip.json |
CIDR to country mapping for geo rules |
config/backends.json |
Multi-vhost routing (one instance, many domains to many backends) |
features.json is read at startup. A missing file just uses the built-in
defaults, so you can delete it and the WAF still runs. A malformed file, a bad
value, or an unknown field stops startup with a clear error instead of silently
dropping protection. Example:
{
"server": { "listen": ":8080", "read_timeout_seconds": 30 },
"backend": { "proxy_url": "http://localhost:9000" },
"challenge_system": { "enabled": true, "default_type": "javascript", "pow_difficulty": 5 },
"fingerprinting": { "enabled": true, "max_ips_per_fingerprint": 5 },
"websocket": { "enabled": true, "max_connections_per_ip": 10 }
}See config/features.example.json for the full annotated set of fields.
Two fields worth knowing about:
server.trusted_proxies: CIDRs whoseX-Forwarded-For,X-Real-IPandCF-Connecting-IPheaders are believed. Empty means loopback plus private ranges, which covers nginx or Traefik on the same box. If the WAF is the public edge, leave it empty and forwarded headers from the internet are ignored, so nobody can spoof their way past a ban with one header. Behind Cloudflare, put the CF ranges here.csrf.enabled: off by default. Turn it on only when your frontend fetches/csrf/tokenand sends it back asX-CSRF-Tokenor acsrf_tokenform field, otherwise every non-exempt POST gets a 403.
ip_rules.json and geoip.json hot-reload while the WAF is running, so IP bans,
geo rules, and rate limits take effect without a restart (see Hot-reload below).
Flags win over environment variables, which win over features.json.
| Flag | Env | Default | Purpose |
|---|---|---|---|
-listen |
RHINOWAF_LISTEN |
:8080 |
Listen address |
-backend |
RHINOWAF_BACKEND |
http://localhost:9000 |
Fallback backend when there is no backends.json |
-config-dir |
RHINOWAF_CONFIG_DIR |
./config |
Where the config files live |
-log-dir |
RHINOWAF_LOG_DIR |
./logs |
Where log files are written |
-features |
RHINOWAF_FEATURES |
<config-dir>/features.json |
Path to features.json |
-version |
Print version and exit | ||
-check-rules |
Compile config and rules, print result, exit (like nginx -t) |
Secrets stay in environment variables (never in the config files):
export HCAPTCHA_SITE_KEY=... HCAPTCHA_SECRET=...
export TURNSTILE_SITE_KEY=... TURNSTILE_SECRET=...
export OAUTH2_CLIENT_ID=... OAUTH2_CLIENT_SECRET=...
export ABUSEIPDB_API_KEY=... IPQS_API_KEY=...
export JWT_SECRET=...ip_rules.json supports a large set of per-IP controls. Short version:
{
"version": "2.0",
"banned_ips": [
{ "ip": "192.0.2.10", "type": "ban", "reason": "repeated attacks" }
],
"whitelisted_ips": [
{ "ip": "10.0.0.1", "type": "whitelist", "whitelist_override": true }
],
"geo_rules": [
{ "country_code": "CN", "action": "challenge", "throttle_percent": 40 },
{ "country_code": "KP", "action": "block" }
],
"global_rules": {
"default_action": "allow",
"block_proxies": true,
"block_tor": true,
"block_hosting": true,
"max_requests_per_ip": 100,
"max_connections_per_ip": 10,
"block_empty_user_agent": true,
"block_suspicious_ua": true
}
}Each rule also supports time windows, path and method filters, header and cookie checks, content-type and upload limits, protocol enforcement, and user-agent rules. Full field reference: docs/configuration/IP_RULES.md.
Four challenge types, set default_type in features.json:
javascript- short JS execution requirement, blocks plain curl/wgetproof_of_work- client-side SHA-256 puzzle, difficulty 1 to 8hcaptcha- needsHCAPTCHA_SITE_KEY/HCAPTCHA_SECRETturnstile- Cloudflare Turnstile, needsTURNSTILE_SITE_KEY/TURNSTILE_SECRET
A visitor completes the challenge once, gets a cookie, and passes through on later requests. Proof-of-work is the one that makes a sustained flood expensive with no external dependency. See docs/features/CHALLENGE_SYSTEM.md.
Fingerprinting is on by default. On first visit a small script collects a canvas
signature, WebGL renderer, fonts, and hardware hints, hashes them, and sets a
cookie. If the same fingerprint shows up across too many IPs, that is a bot
network signal. Tune max_ips_per_fingerprint and friends in features.json,
or turn on require_client_data to block headless browsers that cannot produce
canvas/WebGL data. See docs/features/FINGERPRINTING.md.
Runs before the other checks and scores each request for CL.TE / TE.CL / TE.TE conflicts, duplicate length/encoding headers, header obfuscation, and protocol violations. Each violation has a severity 1 to 5 and the WAF blocks at or above a threshold (4 by default). Metrics land in Prometheus. See docs/features/SMUGGLING_DETECTION.md.
Health, metrics, and reload endpoints are restricted to localhost.
/fingerprint/collect, /csrf/token and /challenge/verify are public, the
browser has to reach them.
curl http://localhost:8080/health # status, uptime, memory, version
curl http://localhost:8080/metrics # Prometheus
curl -X POST http://localhost:8080/reload # force a config reloadMetrics cover requests (allowed/blocked by reason), challenge issue/pass/fail counts, fingerprint stats, request latency histograms, and config reloads. Grafana query examples are in docs/CHANGELOGS/V2.3_FEATURES.md.
Attack logs are JSON, one event per line, at logs/ddos.log:
tail -f logs/ddos.log | jq '.message'
jq -r '.severity' logs/ddos.log | sort | uniq -cip_rules.json, geoip.json and backends.json are watched or reloaded on
demand (backends only via /reload or SIGHUP), no restart needed:
curl -X POST http://localhost:8080/reload # HTTP (localhost)
kill -SIGHUP <pid> # signalIf a reload hits invalid JSON, the previous config stays active.
Build a stripped binary and run it behind your existing proxy:
go build -trimpath -ldflags="-s -w" -o rhinowaf ./cmd/rhinowafRhinoWAF shuts down cleanly on SIGINT/SIGTERM and drains in-flight requests (15 second grace), so it behaves under a process manager. Minimal systemd unit:
[Unit]
Description=RhinoWAF
After=network.target
[Service]
WorkingDirectory=/opt/rhinowaf
ExecStart=/opt/rhinowaf/rhinowaf -config-dir /opt/rhinowaf/config -log-dir /var/log/rhinowaf
Restart=on-failure
Environment=TURNSTILE_SITE_KEY=...
Environment=TURNSTILE_SECRET=...
[Install]
WantedBy=multi-user.targetThe WAF's own listener has read/write/idle timeouts set, so it is not itself an
easy slowloris target. Adjust them under server in features.json.
Numbers from the benchmarks/ suite against an OWASP-style scenario set (1,518
requests across 15 traffic patterns). Run it yourself with go test ./benchmarks/....
| Attack type | Detection |
|---|---|
| SQL injection | 100% |
| XSS | 100% |
| Credential stuffing | 100% |
| File upload attacks | 100% |
| Web scrapers | 100% |
| Bot attacks | ~55% (modern API user-agents are allowed on purpose) |
Legitimate traffic (browser, e-commerce, SaaS, mobile, crawlers, webhooks,
GraphQL) passed at or near 100%, with a low single-digit false-positive rate
under peak-hour load. Overall true-positive rate is around 87% because bot
detection intentionally lets modern API clients (curl 8.x, python-requests)
through to avoid blocking real automation. Latency was sub-millisecond mean on
allowed requests. Tune ip_rules.json for your own traffic mix.
Self-hosting is free under AGPL. If you would rather not fight config files, I also do the hands-on deploy.
| Package | What you get | Price |
|---|---|---|
| Go-live | VPS install, Traefik or nginx in front of your app, Turnstile on flood traffic, L7 rules tuned to your domain, smoke test, short handoff doc | $125 to $250 one-time |
| Care | Weekly health check, small config fixes after you are live | $99/mo (optional) |
| Custom | Admin UI, multi-site, client handoff package | quoted |
Terms: fixed scope, half up front, half once traffic is passing through the WAF. PayPal or Cash App after scope is agreed in writing.
Contact: Discord 1rhino2, or open a
GitHub Discussion with your
stack (VPS, domain, Traefik yes/no). Other work (bots, APIs, scrapers, sites) is
at 1rhino2.github.io.
- IP rules, multi-vhost, production config
- Challenges, fingerprinting, CSRF, smuggling, IPv6
- OAuth2, HTTP/3
- Changelogs live in docs/CHANGELOGS/
AGPL-3.0. Network use counts as distribution, so if you run a modified version as a service you have to publish your changes. See LICENSE.