At-scale components
The data-path components are wired into the waf-enterprise binary and configured in the same TOML file as the core, under [enterprise.*] and [tls.*] tables. All of them are default-off, opt-in, and additive to the same anomaly score.
A file with no
[enterprise.*]tables (or all disabled) behaves exactly like the corewafbinary. The name enterprise survives in the binary and the config tables from when the components were licensed separately; today everything is Apache-2.0.
Every component’s whole config is in the commented sample
examples/enterprise.toml,
and each has a copy-paste runbook under
scripts/.
Cluster-wide rate limiting
Share one rate-limit budget across all WAF nodes via Redis (atomic server-side try_acquire). This
sets only where the bucket state lives; the policy stays in
[rate_limit].
[enterprise]
redis_url = "redis://10.0.0.5:6379" # absent ⇒ in-memory single-node store
pool_size = 32
fail_open = true # Redis error at RUNTIME: true = allow | false = deny.
# An unreachable Redis at BOOT is always fatal.
WAF_REDIS_URL overrides redis_url for ops/CI.
Managed TLS
waf-tls-managed obtains and rotates certificates automatically — hitless (no acceptor rebuild on
renewal). When enabled, cert_path/key_path are ignored; [tls].enabled still governs on/off.
[tls.acme]
enabled = true
domains = ["waf.example.com"] # SANs; dns-01 supports wildcards "*.example.com"
email = "ops@example.com"
strategy = "tls-alpn-01" # "tls-alpn-01" (default) | "dns-01"
cache_dir = "acme-cache" # persists the account + cert across restarts
staging = true # true = CA staging (avoid Let's Encrypt prod rate limits)
tls-alpn-01proves control on the same port via theacme-tls/1ALPN. Needs a reachable public IP on that port.dns-01proves control by publishing a TXT record — works behind NAT and for wildcards. It needs a provider block and credentials in the environment (never in the file):
[tls.acme]
strategy = "dns-01"
provider = "cloudflare" # "cloudflare" | "route53"
[tls.acme.dns]
zone_id = "<zone or hosted-zone id>"
propagation_timeout_secs = 120 # wait for the TXT to appear on the authoritative NS
renewal_days = 60 # renew ahead of the ~90-day LE lifetime
| Provider | Environment variables |
|---|---|
cloudflare |
CF_API_TOKEN |
route53 |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN, AWS_REGION) |
Clustered DNS-01 (cluster = true) coordinates multiple nodes through Redis so exactly one node
issues for the whole cluster and the others converge. It requires [enterprise].redis_url and
WAF_CERT_STORE_KEY (encrypts the private key at rest).
Mutual TLS (client certificates)
[tls.mtls]
enabled = true
ca_path = "/etc/waf/clients-ca.pem" # PEM bundle of trusted client CAs
With enabled = false there is no client authentication — exactly the core behavior.
IP reputation & threat-intel feed
A connection-phase module reads the client IP’s reputation from the same Redis (keyspace rep:<ip>)
and contributes to the score. A background feed populates that keyspace from IP lists. Both
require redis_url.
[enterprise.reputation]
enabled = true
malicious_severity = "critical" # a malicious IP alone can cross the threshold
suspicious_severity = "warning"
[enterprise.reputation.feed]
enabled = true
refresh_interval_secs = 300 # single-writer: one node per cycle fetches (distributed lock)
lock_ttl_secs = 90
[[enterprise.reputation.feed.sources]]
kind = "http" # "file" (local path) | "http" (HTTPS URL)
location = "https://example.com/blocklist.txt"
verdict = "malicious" # malicious | suspicious
Entries expire (TTL), so a de-listed IP ages out and a dead feed degrades to Unknown (fail-safe). v1 handles exact IPs only (CIDRs are skipped with a warning).
Curated CRS content
Loads curated rule content (CVE/app/evasion signatures: Log4Shell, Spring4Shell, cloud-metadata
SSRF, webshells, .git/.env exposure, …) through the core CRS engine. Additive to the same score.
[enterprise.crs_curated]
enabled = true
GraphQL / gRPC schema enforcement
Validate every operation against your app’s real schema. A field/type/method the real client never sends is a probing signal and adds to the score. The schema file is mandatory when enabled, and validated at boot (fatal if missing/invalid).
[enterprise.schema_enforcement]
enabled = true
schema_path = "/etc/waf/app.graphql" # SDL; boot-fatal if missing/invalid
paths = ["/graphql"]
parse_error_severity = "warning"
unknown_field_severity = "critical"
[enterprise.grpc_schema_enforcement]
enabled = true
descriptor_path = "/etc/waf/app.bin" # FileDescriptorSet from protoc/buf
unknown_method_severity = "critical"
decode_error_severity = "warning"
flag_unknown_fields = false # opt-in (protobuf tolerates unknown fields by design)
OpenAPI positive security
Validate every REST/JSON request under the guarded path prefixes against your app’s real OpenAPI
spec (3.0 or 3.1, as a JSON file). Routing uses the normalized path (decoded, traversal resolved),
so encoding tricks do not move a request out of scope; requests outside paths are left alone.
Two families of signals, both additive to the anomaly score:
- Unknown surface —
schema-api-unknown-route(a path the spec does not declare) andschema-api-method-not-allowed(a declared path, undeclared method), scored withunknown_route_severity. This is the strong probing signal. - Contract violations on a known operation —
schema-api-unknown-param,schema-api-missing-required,schema-api-type-violation,schema-api-unknown-property,schema-api-content-type(a body that is notapplication/json/+jsonwhere the spec expects JSON),schema-api-body-parse-error— scored with the lowerviolation_severity.
Injection inside a parameter value (SQLi, XSS…) is still caught by the core detection modules; this
module checks the shape of the request, not its content. The spec is mandatory when enabled and
validated at boot (fatal if missing, invalid or not compilable). It is re-loaded on every config
reload; if the new spec is invalid the reload is aborted and the previous one stays active. The module
never fetches remote $refs.
[enterprise.api_schema]
enabled = true
spec_path = "/etc/waf/openapi.json" # OpenAPI 3.0/3.1, JSON; boot-fatal if missing/invalid
paths = ["/api"] # guarded path prefixes (default ["/api"])
unknown_route_severity = "critical" # undeclared path or method
violation_severity = "warning" # contract violation on a declared operation
strict_params = true # flag query parameters not declared for the operation
strict_body = true # treat request bodies as additionalProperties:false
# even where the spec does not say so
With the default scores (warning = 3, critical = 6, block threshold 5), one contract violation alone
does not block, two different ones do, and an unknown route or method blocks on its own. To roll out
safely, run in mode = "detection-only" first and watch the schema-api-* hits in the decision log.
If a client legitimately sends fields your spec does not list, set strict_body = false (or fix the
spec) rather than lowering the severities.
Bot / client-integrity detection
Native cross-header heuristics. v1 flags browser impersonation: a User-Agent that claims a modern
browser but sends no Sec-Fetch-* headers is a client pretending to be a browser (a curl/requests bot
doesn’t send Fetch-Metadata). Additive (never a hard reject).
[enterprise.bot_detection]
enabled = true
browser_impersonation_severity = "critical"
Signed WASM plugins
The core [modules.wasm] runtime loads unsigned .wasm
best-effort. This adds a trust gate: verify a detached minisign (ed25519) signature against
trusted_keys before loading. A signed plugin that fails verification is boot-fatal
(fail-closed).
[enterprise.wasm]
enabled = true
exclusive = true # reject any populated core [modules.wasm] (lockdown)
trusted_keys = ["RWQ...base64-minisign-pubkey..."]
[[enterprise.wasm.plugins]]
wasm_path = "/etc/waf/denylist.wasm"
sig_path = "/etc/waf/denylist.wasm.minisig"
config = "block=/admin"
Sign with minisign -Sm plugin.wasm (→ plugin.wasm.minisig); the trusted_keys entry is the
base64 line from the .pub.
Request capture
Capture the full denied request (method/path/query, headers, cookies, body) so the control-plane event drill-down can show exactly what was blocked — not just the metadata the decision log carries. Optional, off by default, and it does not touch the data path: a pure observer buffers each request in a bounded ring off-path, and a background shipper posts the captured denied requests to the control plane on a dedicated side-channel (never the decision log — the body never travels in logs). Overflow is dropped, never back-pressured onto the proxy.
It is raw by default, so treat it like a secret store: redact sensitive names here, keep the
control-plane retention short (see Control plane), and know that
viewing a capture is RBAC-gated (ViewCaptures, operator and admin) and written to the signed audit
log.
[enterprise.capture]
enabled = true
controlplane_url = "http://waf-controlplane:9600" # the shipper appends /ingest/captures
token_env = "WAF_CAPTURE_TOKEN" # env holding the ingest bearer — never the token here
max_body_bytes = 65536 # bodies over this are truncated + flagged
ring_capacity = 1024 # ring bounds: entries …
ring_bytes = 33554432 # … total bytes (32 MiB) …
ring_ttl_ms = 5000 # … and TTL before an unshipped capture is evicted
queue_capacity = 256 # shipper backlog; full ⇒ drop (never block the proxy)
# Names replaced with ***REDACTED*** node-side (case-insensitive). Empty = raw. The redacted VALUE
# never leaves the node — only the redacted names are reported, to drive the UI banner.
[enterprise.capture.redaction]
headers = ["cookie", "authorization", "set-cookie"]
body_fields = ["password", "token", "secret", "card"]
The ingest bearer comes from the environment (WAF_CAPTURE_TOKEN), matching the control plane’s
WAF_CP_API_TOKEN (ingest is bearer-gated). Enabling capture without a controlplane_url is
boot-fatal (a capture that can never ship is a silent misconfiguration).
Scope: connection-phase rejects (rate limit) and parser-limit 400s happen before inspection,
so those events keep metadata only — there is no captured body for them.