Web application firewall
Put the embedded OWASP Core Rule Set in front of a route, layer your own SecLang rules from ConfigMaps, and ship full rulesets as files.
Use case: you want a WAF in front of an application: the OWASP Core Rule Set as a baseline, your own ModSecurity-style rules on top, and a way to ship whole rule files when they outgrow inline configuration.
This tutorial reuses the hello Service and edge Gateway from
Hello, HTTP.
1. Enable the Core Rule Set
The WAF is an extension: a ConfigMap holding a
waf.hcl document, attached to a route rule with an ExtensionRef
filter. The OWASP Core Rule Set and the
Coraza recommended configuration are embedded in the
krouter binary, so enabling them is three @-includes:
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-waf
data:
waf.hcl: |
version = 1
waf {
directives = <<-EOT
Include @coraza.conf-recommended
Include @crs-setup.conf.example
Include @owasp_crs/*.conf
SecRuleEngine On
EOT
}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hello
spec:
parentRefs:
- name: edge
hostnames:
- hello.example.com
rules:
- filters:
- type: ExtensionRef
extensionRef:
group: ""
kind: ConfigMap
name: hello-waf
backendRefs:
- name: hello
port: 80
The filter attaches per rule, not per route: you can keep / unprotected
and put the WAF only on the rule matching /api, or give different rules
different rulesets.
2. Attack yourself
A legitimate request passes untouched:
GW_IP=$(kubectl get gateway edge -o jsonpath='{.status.addresses[0].value}')
curl -i -H 'Host: hello.example.com' "http://$GW_IP/?q=hello"
A cross-site-scripting probe is interrupted before it reaches the backend:
curl -i -H 'Host: hello.example.com' \
"http://$GW_IP/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E"
The client receives 403 Forbidden and the backend never sees the
request. Request bodies are inspected too, buffered up to the engine’s
limits (SecRequestBodyLimit and friends), then replayed to the backend
unchanged:
curl -i -H 'Host: hello.example.com' \
--data "username=admin' OR 1=1--" "http://$GW_IP/login"
3. Layer your own rules
This is where rule files would normally come in. In krouter there are no
files: each waf.hcl fragment plays the role of one .conf file, and
the filter list is the include order. Add a second ConfigMap with your
own directives:
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-waf-tuning
data:
waf.hcl: |
version = 1
waf {
directives = <<-EOT
SecRule REQUEST_HEADERS:User-Agent "@contains sqlmap" \
"id:1001,phase:1,deny,status:403,log,msg:'Scanner UA blocked'"
EOT
}
Then reference it after the base one, in the same rule:
filters:
- type: ExtensionRef
extensionRef:
group: ""
kind: ConfigMap
name: hello-waf
- type: ExtensionRef
extensionRef:
group: ""
kind: ConfigMap
name: hello-waf-tuning
The fragments concatenate in filter list order into one SecLang program, so later fragments see everything the earlier ones defined. That gives you the usual ModSecurity layering: the base ConfigMap carries the CRS (shared by many routes), the later one carries route-specific rules and exclusions. Tuning away a false positive is one directive in the later fragment:
SecRuleRemoveById 942100
Verify your new rule:
curl -i -A 'sqlmap/1.7' -H 'Host: hello.example.com' "http://$GW_IP/"
4. Change rules without restarts
Edit either ConfigMap and the control plane compiles a new configuration generation; data-plane pods swap it atomically. No pods restart, no volumes are mounted, and in-flight requests finish on the old ruleset.
Validation happens at compile time, before any request reaches the new program. Break a directive on purpose:
kubectl patch configmap hello-waf-tuning --type merge -p '
data:
waf.hcl: |
version = 1
waf {
directives = "SecRule oops"
}
'
kubectl describe httproute hello
The route stays accepted, but ResolvedRefs turns False with reason
InvalidExtensionRef, and requests matching the rule are answered 500
(fail closed, never silently unprotected). Fix the ConfigMap and the
route recovers on its own.
5. Bring your own rule files
ConfigMaps are the day-to-day mechanism, but a large ruleset (a CRS fork,
a fat IP blocklist) can outgrow the
1 MiB ConfigMap limit
or already live in files you would rather ship verbatim. Include of a
filesystem path reads rule files from the krouter pods themselves:
version = 1
waf {
directives = <<-EOT
Include /etc/krouter/rules/custom.conf
Include /etc/krouter/rules/blocklists/*.conf
EOT
}
Getting the files onto the pods means tinkering with the deployment itself, not with Gateway API resources. Two recipes:
Extend the image. The krouter image is built from scratch and contains only the binary, so adding rules is one COPY layer:
FROM ghcr.io/link-society/krouter:latest
COPY rules/ /etc/krouter/rules/
Point both the control-plane Deployment and the data-plane DaemonSet at your image and every pod carries the files.
Mount volumes. Keep the stock image and patch the manifest with Kustomize, mounting your rules (here from a ConfigMap, but any volume works) into both workloads:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- https://raw.githubusercontent.com/link-society/krouter/main/k8s/krouter.yaml
configMapGenerator:
- name: krouter-rules
namespace: krouter-system
files:
- rules/custom.conf
patches:
- patch: |-
- op: add
path: /spec/template/spec/volumes
value:
- name: rules
configMap:
name: krouter-rules
- op: add
path: /spec/template/spec/containers/0/volumeMounts
value:
- name: rules
mountPath: /etc/krouter/rules
readOnly: true
target:
kind: Deployment
name: krouter-controlplane
- patch: |-
- op: add
path: /spec/template/spec/volumes
value:
- name: rules
configMap:
name: krouter-rules
- op: add
path: /spec/template/spec/containers/0/volumeMounts
value:
- name: rules
mountPath: /etc/krouter/rules
readOnly: true
target:
kind: DaemonSet
name: krouter-dataplane
Both workloads need the files: the control plane builds the engine once
at compile time to validate the program (a missing or broken file
surfaces as InvalidExtensionRef, exactly like the broken directive in
step 4), and every data-plane pod builds it again to enforce it.
One caveat that ConfigMap directives do not have: rule files are read when the engine builds, not watched. Editing a file in place does not re-trigger compilation, so roll the pods (a new image tag does this naturally) to apply new file contents.
Two closing notes:
- The same ConfigMap can also carry a
ratelimit.hclkey: rate limiting runs first, so a limited request costs no WAF CPU. See Extensions for composition and status details. - On GRPCRoute rules the WAF inspects request
headers and buffers the request message like an HTTP body. The stock
CRS rejects the
application/grpccontent type at the header phase (rule 920420), so allow it in a later fragment before putting the CRS in front of gRPC services, and prefer scoping the WAF to unary methods since message buffering delays streams.
Decisions are observable as krouter_dataplane_waf_decisions_total and
in the access log; see Observability.
That’s the full tour, from one route to a WAF-protected, multi-tenant edge. For the guarantees behind all of it, read the conformance page and the test report.