Security & WAF
bext includes an enterprise-grade Web Application Firewall (WAF) that inspects every request through six layered defense stages. The WAF runs in-process with zero per-request regex compilation cost -- all patterns are compiled once at startup. This is a Pro feature; Community edition includes the default security headers and basic rate limiting.
Defense Layers
Requests pass through these stages in order. The first non-Allow decision terminates the chain:
- IP Filter -- CIDR-based allow/deny lists
- GeoIP Blocking -- country-level access control
- DDoS Guard -- connection limits, body/header size caps
- Rate Limiting -- token-bucket per-path rules
- Bot Detection -- multi-signal behavioral analysis
- Rule Engine -- SQL injection, XSS, path traversal, shell injection, scanner detection, custom rules
WAF Configuration
[waf]
enabled = true
# IP filtering (mode: "allow", "deny", or "off")
[waf.ip_filter]
mode = "deny"
deny_list = ["10.0.0.0/8", "203.0.113.50"]
allow_list = ["10.0.0.5"] # Allow list overrides deny list
# GeoIP blocking (requires MaxMind GeoLite2 database)
[waf.geo]
enabled = true
mode = "deny" # or "allow" for whitelist mode
countries = ["CN", "RU"] # ISO 3166-1 alpha-2 codes
bypass_paths = ["/health", "/api/status"]
real_ip_header = "X-Forwarded-For" # Extract real IP from proxy header
# Request inspection rules
[waf.rules]
sql_injection = true
xss = true
path_traversal = true
shell_injection = true
scanner_detection = true
protocol_violations = true
# Bot detection
[waf.bot]
enabled = true
mode = "block" # "block", "challenge", or "log_only"
signal_threshold = 2 # Number of signals to trigger detection
good_bots = [ # Always allowed (case-insensitive match)
"Googlebot", "Bingbot", "Slurp", "DuckDuckBot",
"facebookexternalhit", "Twitterbot", "Applebot"
]
# DDoS mitigation
[waf.ddos]
max_connections_per_ip = 100
max_new_connections_per_second = 500
max_request_body_size = 10485760 # 10 MB
header_count_limit = 100
header_size_limit = 16384 # 16 KB
slowloris_timeout_ms = 10000
# Path-specific rate limiting
[[waf.rate_limit_rules]]
name = "api_global"
pattern = "/api/**"
rpm = 600 # Requests per minute
burst = 50 # Extra burst capacity
key_source = "ip" # "ip", {"header": "X-Api-Key"}, {"cookie": "session"}
[[waf.rate_limit_rules]]
name = "auth_strict"
pattern = "/api/auth/**"
rpm = 20
burst = 5
key_source = "ip"
# Throttle a whole CLASS of clients with one shared bucket. `key_source =
# "global"` means every request matching the rule shares a single budget —
# useful for distributed crawlers that each hit at a low per-IP rate but
# collectively saturate the backend. `user_agent_contains` (case-insensitive)
# gates the rule to specific UAs, so it can target scrapers without ever
# touching real users or search engines (which simply aren't listed).
[[waf.rate_limit_rules]]
name = "aggressive_crawlers"
pattern = "*"
rpm = 30 # whole-class budget (not per-IP)
burst = 15
key_source = "global"
user_agent_contains = ["AhrefsBot", "SemrushBot", "ClaudeBot", "GPTBot", "meta-externalagent"]
# Custom blocking rules
[[waf.custom_rules]]
name = "block-admin-external"
action = "block"
status = 403
reason = "Admin access denied from external network"
[waf.custom_rules.match_config]
path = "/admin/**"
IP Filtering
The IP filter supports both individual addresses and CIDR ranges for IPv4 and IPv6. In deny mode, the allow list takes priority over the deny list, letting you carve out exceptions:
[waf.ip_filter]
mode = "deny"
deny_list = ["192.168.0.0/16"]
allow_list = ["192.168.1.100"] # This specific IP is allowed despite the range deny
In allow mode, only listed IPs can access the server -- all others receive a 403.
The IP filter supports hot-reload: update the configuration and send SIGHUP to apply new rules without restarting the server.
GeoIP Blocking
Country-level blocking uses MaxMind's GeoLite2-Country database. Download the free .mmdb file and point bext to it:
geoip_db_path = "/etc/bext/GeoLite2-Country.mmdb"
In deny mode, unknown countries (lookup failures) are allowed through. In allow mode, unknown countries are blocked -- this is the more restrictive default for compliance scenarios.
The real_ip_header setting handles proxy chains. For X-Forwarded-For: 1.2.3.4, 5.6.7.8, bext extracts the first IP (1.2.3.4) as the client address.
Bot Detection
The bot detector scores requests against five behavioral signals:
- Missing
User-Agentheader - Missing
Acceptheader - Missing
Accept-Languageheader - Missing
Accept-Encodingheader - Known scanner user-agent (sqlmap, Nikto, etc.)
When the signal count meets the threshold, the configured action triggers:
- block -- returns 403 immediately
- challenge -- serves a JavaScript challenge page; headless bots without a JS engine fail
- log_only -- records the detection without blocking (useful for tuning thresholds)
Known good bots (Googlebot, Bingbot, etc.) are exempted by default using case-insensitive user-agent substring matching.
DDoS Protection
The DDoS guard enforces multiple limits simultaneously:
- Per-IP connection limit -- prevents a single IP from monopolizing server resources (default: 100 concurrent connections)
- Global connection rate -- caps new connections per second to prevent SYN flood saturation (default: 500/s)
- Body size limit -- rejects oversized payloads with 413 (default: 10 MB)
- Header abuse -- limits header count (100) and total header size (16 KB) to prevent Slowloris-style attacks
Connection tracking is bounded at 100,000 entries to prevent the guard itself from becoming a memory exhaustion vector.
Rate Limiting
Rate limit rules use a token-bucket algorithm with configurable burst capacity. Keys can be extracted from:
| Key source | Use case |
|---|---|
ip |
General API protection (normalizes IPv6 to /64) |
header("X-Api-Key") |
Per-API-key limits |
cookie("session") |
Per-session limits |
global |
One shared bucket for a whole class (e.g. distributed crawlers that each hit at a low per-IP rate but collectively saturate the backend) |
A rule may also set user_agent_contains = [...] (case-insensitive substrings) so it
only fires for matching User-Agents — letting you throttle a crawler class without
ever touching real users or search engines, which simply aren't listed.
When a request is rate-limited, bext returns 429 with standard headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 600
RateLimit-Remaining: 0
Built-in crawler throttle
Aggressive non-search crawlers (SEO/AI scrapers like AhrefsBot, SemrushBot,
ClaudeBot, GPTBot, meta-externalagent, …) can sweep a site's long-tail URLs into
cold backend renders — on a busy multi-tenant box this is easily a quarter of total
render time. bext ships a ready-made global rate-limit rule for that class, gated
by one env var:
BEXT_WAF_CRAWLER_RPM=30 # whole-class budget per minute; 0 (default) = off
It's off by default, search engines (Googlebot/Bingbot/…) are never matched,
and real users are never affected. Pick the rate by intent: a value at or above
the crawler class's steady rate (e.g. 30) is storm protection — it only sheds
genuine floods and leaves normal crawling alone; a value below it (e.g. 10)
actively sheds load but throttles third-party SEO tools harder. Watch
waf_rate_limits_total and waf_crawler_ua_matched_total on /__bext/metrics to
tune it against real traffic — the second counts requests that reached the limiter
and matched the crawler UA gate, so you can confirm the rule is exercised before
trusting it to shed load.
Default Security Headers
Even without the WAF enabled, bext injects baseline security headers on every response:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
SAMEORIGIN |
Referrer-Policy |
strict-origin-when-cross-origin |
X-XSS-Protection |
0 (disables legacy auditor per modern best practice) |
Content-Security-Policy |
default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none' |
Strict-Transport-Security |
max-age=63072000; includeSubDomains (TLS only) |
CORS Configuration
[cors]
allowed_origins = ["https://app.example.com", "https://admin.example.com"]
max_age_secs = 3600
When no origins are configured, bext defaults to restrictive CORS (no cross-origin requests allowed). Allowed methods include GET, POST, PUT, DELETE, PATCH, and OPTIONS. Custom headers x-tenant-id and x-site-id are included by default.
Monitoring
WAF statistics are available at /__bext/admin/api/waf/stats and in Prometheus format at /__bext/metrics:
{
"total_requests": 184920,
"allowed": 184501,
"blocked": 389,
"rate_limited": 27,
"challenged": 3
}
The WAF audit event feed at /__bext/waf/events records each block decision with the client IP, path, rule name, and timestamp for forensic analysis.
The WAF (IP filtering, GeoIP, bot detection, and rule engine) is a Pro
feature. Community edition only includes the default security headers and
basic rate limiting. Enabling [waf] enabled = true on Community silently
no-ops the advanced layers.
The default Content-Security-Policy header uses script-src 'self' 'unsafe-inline'
and does not allowlist external CDNs (Google Fonts, analytics scripts, etc.).
Pages that load resources from third-party origins will silently fail in real
browsers even though curl responses look fine. Add any needed origins to the
CSP or self-host the assets.
Related
- TLS & HTTPS — HSTS, certificate management, and HTTPS enforcement
- Multi-App — per-app CORS and header overrides
- Operations — Monitoring — Prometheus metrics including
bext_waf_*counters - Operations — Logging — structured WAF audit log access
- Auth capability — authentication layer that complements WAF-level controls
- Authz Policy — fine-grained authorization rules beyond IP/geo filtering