Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Web application firewall

Barbacane can run a ModSecurity-compatible rule set, such as the OWASP Core Rule Set, as a native stage in the request pipeline. The rule set is validated when you compile the artifact and sealed into it, so the gateway never parses rules at request time and an unknown directive fails the build rather than becoming a rule that silently never fires.

This complements spec validation rather than duplicating it. The spec is a positive model: it says what a request may look like, and anything outside the schema is rejected. The rule set is a negative model: it recognises attack shapes whether or not they are schema-valid. A perfectly schema-valid ?q=1' OR 1=1-- passes validation, and is exactly what the rule set catches.

Enabling it

Declare x-barbacane-waf at the root of your spec:

x-barbacane-waf:
  ruleset: ./crs-4.9.0/rules   # directory of SecLang .conf files
  paranoia_level: 1            # 1-4
  mode: blocking               # blocking | detection-only
  thresholds:
    inbound: 5
    outbound: 4
  max_response_body: 1048576   # bytes; phase-4 body inspection cap (default 1 MiB)
  audit: relevant-only         # off | relevant-only (default) | on
  unsupported_rules: fail      # fail (default) | skip

thresholds.inbound blocks a request when its accumulated inbound anomaly score crosses it; thresholds.outbound does the same for the response, scored by phase 3 and 4 rules. max_response_body bounds phase-4 body inspection: a buffered response body at or under it is inspected, a larger or streamed one is not (see Response-phase inspection). audit controls per-transaction audit logging (see Audit logging). All of these are covered by artifact_hash.

ruleset is resolved relative to the spec. Point it at a directory containing the .conf files and, for CRS, the setup file:

curl -sL https://github.com/coreruleset/coreruleset/archive/refs/tags/v4.9.0.tar.gz | tar xz
mkdir -p crs-4.9.0/rules
cp coreruleset-4.9.0/rules/*.conf coreruleset-4.9.0/rules/*.data crs-4.9.0/rules/
cp coreruleset-4.9.0/crs-setup.conf.example crs-4.9.0/rules/000-crs-setup.conf

The setup file is not optional. Without it CRS blocks every request at rule 901001, which exists precisely to stop a half-configured deployment from looking like it works.

Pin the CRS version. A floating reference makes two builds of the same spec enforce different rules, and the rule set is covered by the artifact hash and signature, so the artifact is only reproducible if its inputs are.

What compilation does

barbacane compile parses the rule set, refuses anything it cannot enforce, and seals the validated form into the artifact along with the @pmFromFile phrase lists the rules reference. The rule set and the policy are both folded into artifact_hash, so a signed artifact cannot be switched from blocking to detection-only, or have its paranoia level lowered, without invalidating the signature.

A rule the build cannot enforce fails the build. Stock CRS v4.9.0 compiles in full; a failure comes from a custom rule, for example an unknown directive, a missing @pmFromFile data file, or an invalid regex. unsupported_rules: skip covers only rules whose operator will not compile; a parse error or a missing data file always fails the build:

error[E1080]: x-barbacane-waf: 1 rule(s) in the rule set cannot be enforced by
this build: rule 900500 (line 12): @rx (: unclosed group
...
Set `unsupported_rules: skip` to build without them. The artifact then records
their ids and the gateway will not enforce them.

That is the default because shipping the rest of a rule set as though it were complete is how a rule becomes a bypass. unsupported_rules: skip is the explicit opt-in, and it is not silent: the compiler warns with the rule ids, the ids go into the manifest where the hash covers them, and the gateway logs them at WARN on every boot.

Response-phase inspection

Response-phase rules (CRS phases 3, 4 and 5) run on the response the same transaction started on the request, so outbound blocking rules read the scores the inbound rules accumulated. Phase 3 inspects response headers, phase 4 inspects the response body, and phase 5 is logging and correlation.

Phase 4 body inspection has a bound. A buffered response body at or under max_response_body (default 1 MiB) is collected and inspected. A response that is streamed, or whose body is larger than the cap, has its headers inspected (phase 3) but its body skipped: it has already begun reaching the client by the time a phase-4 rule could act on it. Each skip is counted in barbacane_waf_response_body_skipped_total rather than dropped silently, so a rule set that promises outbound body inspection can be checked against what the gateway actually inspects. A WebSocket upgrade has no response phases.

Audit logging

The WAF writes one structured record per transaction on the waf.audit tracing target, carrying the request id, client address, method, path, the rules that matched (id, message, logdata, tags, matched variable), the inbound and outbound anomaly scores, the verdict, and the response status. Route that target to its own sink to feed a SIEM.

audit controls when a record is written:

ValueBehaviour
offNever.
relevant-only (default)Only when the transaction was blocked or matched at least one rule that logs. Near-zero volume on clean traffic; the CRS crs-setup default.
onEvery inspected transaction.

A rule’s nolog action keeps it out of the record, matching how it keeps a rule out of the ModSecurity audit log; the rule still matched and still scored. The number of records written is exported as barbacane_waf_audit_total.

A transaction the WAF allowed but spec validation then rejects before dispatch is still audited, so a request the WAF inspected is not lost from the log because a later stage refused it. A payload-size rejection happens before the WAF runs, so there is no inspection to audit.

Current limitations

Read these before enabling it in production.

Cost is around 2 ms per request. Measured with full CRS at paranoia level 1 through a real gateway: 1.9 ms mean, 97% under 2.5 ms. One core sustains on the order of 500 requests per second of inspection. Cost scales with the number of inspected values, so a request with ten query parameters costs roughly twice one with a single parameter.

That is what a full rule set costs, and it is comparable to other CRS implementations, but it dominates any per-request budget it shares: spec validation is about 1.2 µs by comparison. Enable it where it earns its cost rather than globally by reflex, and prefer a lower paranoia level over a higher one until you have tuned for false positives.

Rule-set tuning is your responsibility. A WAF is not plug-and-play. Run in detection-only first, watch barbacane_waf_matched_total by rule id, and add exclusions before switching to blocking. Note the metric: barbacane_waf_blocked_total stays at zero in detection-only mode, because nothing is blocked, so it tells you nothing while you are tuning. The allowed counter and the duration histogram do still record, so you can size the cost before you switch blocking on, but only the matched counter tells you which rule is responsible. Upstream says the same thing, and it is the single most common reason a WAF gets turned off again.

Observing it

Six metrics on the admin endpoint:

MetricMeaning
barbacane_waf_matched_total{method,path,rule_id}Rules that matched, whether or not the request was blocked. This is the tuning signal: a single noisy rule is usually the whole false-positive problem, and this is the only metric that identifies which rule.
barbacane_waf_blocked_total{method,path,rule_id}Requests actually interrupted, by the rule that did it. Zero in detection-only mode.
barbacane_waf_allowed_total{method,path}Requests inspected and allowed. With the above, the block rate.
barbacane_waf_duration_seconds{method,path}Time spent inspecting, so the WAF’s share of latency is visible rather than inferred.
barbacane_waf_response_body_skipped_total{method,path}Responses whose body phase-4 rules did not inspect, because it was streamed or over max_response_body. Response headers were still inspected.
barbacane_waf_audit_total{method,path}Per-transaction audit records written, governed by the audit policy.

barbacane_waf_matched_total counts every rule that matched, including the control-flow rules CRS uses to gate paranoia levels. Those are pass,nolog rules whose only job is to skip a block of higher-paranoia rules, and they match on most requests, so they dominate the counter by volume. Filter them out when reading the tuning signal: the rule ids that matter are the ones that carry a score.

A blocked request is logged at WARN with the rule id, the accumulated anomaly score and the rule’s message, and answers 403 with an RFC 9457 body. The rule id and message appear in the response body only in dev mode: in production they tell an attacker exactly which rule to shape the next payload around.

How blocking actually happens

With CRS, individual rules mostly do not block. They add to an anomaly score, and a final rule blocks when the score crosses thresholds.inbound. So the rule id in a block log is usually 949110, the blocking-evaluation rule, and the rules that contributed are in the score:

{"level":"WARN","message":"WAF blocked request","rule_id":949110,
 "status":403,"inbound_score":18,"path":"/search"}

This is why thresholds.inbound is the main tuning dial: lowering it blocks more, raising it blocks less, and neither requires touching the rules.