DibaGuard / docs /INTEGRATIONS.md
DibaAi's picture
DibaGuard: public licence-clean release (Dibachain)
5d97275 verified
|
Raw History Blame Contribute Delete
36.6 kB
# DibaGuard β€” load-balancer integrations
> **Open-release copy** ([DibaAi/DibaGuard-Open](https://huggingface.co/DibaAi/DibaGuard-Open)). This document was written for the private build of DibaGuard. Numbers that involve the HTTP request model (`http_v2`) were measured with that model, which is **not** part of the open release; the open release ships the licence-clean network models described in `CLEAN_MODELS.md`. Licence statements inside this document are superseded by `DATA_LICENSES.md`, `CLEAN_MODELS.md` and the repository's `LICENSE`, `LICENSE-WEIGHTS` and `NOTICE` files. Sections of the original that were written in Persian were removed from this copy; nothing else was edited.
DibaGuard can sit behind the load balancer you already run instead of putting `dibaguard-proxy` inline. Two
mechanisms, usable together:
1. **Verdict service** β€” `dibaguard-authz` answers "may this request pass?" for nginx `auth_request`,
ingress-nginx `auth-url`, Envoy `ext_authz`, Traefik `ForwardAuth`, Caddy `forward_auth` and HAProxy lua
auth-request. The verdict is exactly the inline proxy's decision (`dibaguard.Decider`, `go/dibaguard/decide.go`):
the sidecar's standing decision for the client (block / challenge / rate-limit, polled from
`guard_fast_state`), the payload signatures (`MatchSigs`) and the site-conditioned HTTP model.
2. **HAProxy blocklist sync** β€” `dibaguard-authz -haproxy-socket ...` mirrors the sidecar's block set into an
HAProxy ACL (or map) through the Runtime API, so HAProxy drops blocked sources in its own data path with **no
per-request round trip**.
Everything below binds to loopback / a private network. The verdict service is not meant to be reachable from the
internet.
| load balancer | mechanism | endpoint | status of this guide |
|---|---|---|---|
| nginx | `auth_request` | `/nginx` | **verified against nginx 1.30.6** (Β§13) + contract tests |
| Kubernetes ingress-nginx | `auth-url` (+ `auth-signin` for the challenge) | `/nginx`, `/_dibaguard/challenge` | contract tests |
| Envoy | `ext_authz`, HTTP service | `/envoy/...` (`path_prefix`) | contract tests |
| Traefik | `ForwardAuth` | `/forward` | contract tests |
| Caddy | `forward_auth` | `/forward` | contract tests |
| HAProxy | Runtime API ACL/map sync | β€” | tested against a fake Runtime API |
| HAProxy | lua auth-request (haproxy-lua-http) | `/forward` | contract tests |
---
## 1. Running `dibaguard-authz`
```sh
cd guard/go/dibaguard
go build -o dibaguard-authz ./cmd/dibaguard-authz # stdlib only
./dibaguard-authz -listen 127.0.0.1:9300 -models ./models -sidecar http://127.0.0.1:8787 \
-mode report -trusted 127.0.0.1/32 -challenge-secret "$(cat /etc/dibaguard/challenge.secret)"
```
Start in `-mode report` (never denies, only reports; Β§10), switch to `-mode enforce` when the verdict headers look
right.
**Feed token.** The sidecar refuses traffic feeds that carry sources unless they bring the feed token (validation
finding V-16: otherwise anyone who can reach it could make the layer block an address of their choice). Set the same
`DIBAGUARD_FEED_TOKEN` in the environment of the sidecar and of every `dibaguard-proxy` / `dibaguard-authz`; they send
it as `X-DibaGuard-Feed-Token` with their request summaries. Polling the standing decisions (`guard_fast_state`) needs
no token.
| flag | default | meaning |
|---|---|---|
| `-listen` | `127.0.0.1:9300` | address; `unix:/run/dibaguard.sock` for a unix socket; `""` = HAProxy syncer only |
| `-models` / `-model` | `models` / β€” | model dir (`http_v2/http_tier1_v2.bin`) or explicit model file |
| `-sidecar` | `http://127.0.0.1:8787` | Python sidecar (`python -m dibaguard.llm http`); `""` = none |
| `-mode` | `report` | `report` \| `enforce` |
| `-inline-block` | `true` | refuse on content (hard signatures, very high scores); `false` = content only flags |
| `-challenge-secret` | random | HMAC key of the challenge cookie β€” **share it across replicas** |
| `-rate` | `5` | req/s for a rate-limited client (burst 2Γ—) |
| `-trusted` | `127.0.0.0/8,::1/128` | CIDRs of the load balancers whose forwarding headers are believed (Β§3) |
| `-ip-headers` | `X-Forwarded-For` | client-address headers read from trusted peers, first present wins: `X-Forwarded-For`, `Forwarded`, `X-Real-IP` β€” list only headers your LB sets or overwrites (Β§3) |
| `-proxy-protocol` | `false` | connections start with a PROXY v1/v2 header from a `-trusted` peer; others are dropped (Β§3) |
| `-ipv6-block-prefix` | `64` | a sidecar block on an IPv6 address covers its /N, here and in the HAProxy ACL (`128` = the address only) |
| `-ipv6-ratelimit-prefix` | `128` | rate-limit entries and token buckets per IPv6 /N (`64` = one bucket per /64) |
| `-envoy-prefix` | `/envoy` | Envoy `path_prefix` |
| `-budget` | `25ms` | per-request budget (body read + decision); over it the fail mode applies |
| `-fail-closed` | `false` | deny when the decision cannot be made (default: fail open) |
| `-state-stale` | `10s` | sidecar state older than this counts as unavailable |
| `-max-inflight` | 64 Γ— GOMAXPROCS | concurrent decisions; no slot within the budget = `overload` |
| `-max-body` | 1 MiB | body bytes inspected when the LB sends the body (Envoy `with_request_body`) |
| `-poll` | `2s` | sidecar state poll interval |
| `-haproxy-socket` | β€” | Runtime API: `127.0.0.1:9999`, `ipv4@host:port`, `unix:/run/haproxy/admin.sock`, `/path.sock` |
| `-haproxy-kind` | `acl` | `acl` \| `map` |
| `-haproxy-file` | `/etc/haproxy/dibaguard-block.acl` | the ACL/map exactly as `haproxy.cfg` loads it (or `#<id>`) |
| `-haproxy-actions` | `block` | which standing decisions are mirrored (`block,challenge,rate_limit` for a map) |
| `-haproxy-every` / `-haproxy-resync` | `2s` / `1m` | sync interval / full re-read interval |
`GET /healthz` returns counters (`checks, allowed, blocked, challenged, rate_limited, flagged, fail_open,
fail_closed_denied, timeouts, overloads, panics`), `degraded`, `sidecar_state_age_s` and the HAProxy syncer's stats.
---
## 2. The verdict contract
| verdict | `/nginx` (auth_request, ingress-nginx) | `/forward` (Traefik, Caddy, HAProxy lua) and `/envoy/...` |
|---|---|---|
| allow | **200** | **200** (Envoy: only 200 allows) |
| block | **403** | **403** `forbidden` |
| challenge | **401** (render with `error_page 401` β†’ `/challenge`) | **429** + the JS challenge page (HTML) |
| rate_limit | **401** (same `error_page`; `/challenge` answers 429 `slow down`) | **429** `slow down`, `Retry-After: 1` |
| fail-closed deny | 403 | `/forward` 503, `/envoy` 403 (Envoy lets a 5xx through under `failure_mode_allow`) |
| any verdict in report mode | 200 | 200 |
nginx's `auth_request` only understands 2xx, 401 and 403 β€” any other status (429 included) becomes a 500. That is
why challenge and rate_limit share 401 on `/nginx` and are told apart by `X-DibaGuard-Verdict`.
Response headers (every answer):
| header | value |
|---|---|
| `X-DibaGuard-Verdict` | `allow` \| `block` \| `challenge` \| `rate_limit` β€” in report mode: what enforce would do |
| `X-DibaGuard-Reason` | `sidecar:<action>` \| `sig:<id>` (first signature) \| `model` \| `fail_open:<cause>` \| `fail_closed:<cause>` |
| `X-DibaGuard-Sigs` | every signature hit, e.g. `A15,A18` |
| `X-DibaGuard-Score` | HTTP model attack logit |
| `X-DibaGuard` | `flagged` (as `dibaguard-proxy` sets it) |
| `X-DibaGuard-Mode` | `report` \| `enforce` |
| `X-DibaGuard-Degraded` | `model_unavailable` \| `sidecar_unavailable` (fail-open while degraded) |
How the original request is reconstructed:
| | method | URI | host |
|---|---|---|---|
| `/nginx`, `/forward` | `X-Original-Method`, else `X-Forwarded-Method`, else the request's | `X-Original-URI`, else `X-Forwarded-Uri`, else `X-Original-URL` (absolute, ingress-nginx) | `X-Forwarded-Host`, else from `X-Original-URL`, else `Host` |
| `/envoy/...` | the request's (Envoy keeps it) | the raw path minus `-envoy-prefix` (never cleaned or redirected) | `:authority` |
All other request headers are the original client headers (all of them for nginx/Traefik/Caddy/HAProxy, Envoy's
`allowed_headers`). The decision reads `User-Agent, Cookie, Content-Type, Accept, Referer, X-Forwarded-For,
Authorization, X-Api-Version, Origin` β€” the same canonical text as the inline proxy.
Decoding follows the application, not Go's URL parser (the same in `dibaguard-proxy` and `dibaguard-authz`): the
query is split on `&` only (a `;` is data) and every pair is kept; well-formed `%XX` and IIS-style `%uXXXX` escapes
are decoded (up to 4 rounds, double encoding included) and a malformed one (`%zz`, `%2`, a lone `%`) is left as it
is, as PHP and most stacks read it and as the Python canonicaliser (`dibaguard/http/canon.py`) does. Before,
`?id=%zz1' or 1=1-- -` and `?host=127.0.0.1;cat /etc/passwd` were dropped by Go's query parser and never scored.
A URI whose *path* Go cannot parse (bad escape) is decoded the same lenient way.
`/challenge` (also `/_dibaguard/challenge`) renders the JS challenge for the client: the page sets the `dg_ch`
cookie (HMAC of client address + UTC day) and reloads β€” or, with `?rd=/local/path`, goes back to that path (only
local paths are honoured; anything else just reloads). It answers 429 `slow down` instead when
`X-DibaGuard-Verdict: rate_limit` is sent or, without that header, when the sidecar rate-limits the client.
---
## 3. Client identity and trusted proxies
The client address is what every sidecar decision, rate-limit bucket and challenge cookie is keyed on, so it must
not be forgeable.
The same rules apply to `dibaguard-authz` and `dibaguard-proxy` (which takes the same `-trusted`, `-ip-headers`,
`-proxy-protocol`, `-ipv6-block-prefix` and `-ipv6-ratelimit-prefix` flags; its old `-trust-xff`, which believed the
leftmost `X-Forwarded-For` entry from anyone, is removed and refuses to start: set `-trusted` instead).
* Forwarding headers are read **only when the TCP peer is in `-trusted`** (default: loopback only; or a
unix-socket peer). Any other peer is identified by its own address whatever it sends: it can neither escape its
own block nor frame another address (both are tested, and verified through real nginx and the real proxy in Β§13).
* `X-Forwarded-For` and `Forwarded` (RFC 7239, its `for=` values, quoted IPv6 with ports included) are walked
**right to left past trusted hops**: `"<client-written>, <real>"` yields `<real>`. A hop that cannot be read
(`unknown`, an obfuscated `_id`, an element without `for=`, garbage) stops the walk. `X-Real-IP` holds one address.
* **List only headers your load balancer sets or overwrites** in `-ip-headers` (default `X-Forwarded-For`). A header
the LB passes through untouched is written by the client: most LBs pass `Forwarded` and `X-Real-IP` through
unless configured to set them, so they are opt-in.
* **PROXY protocol** (`-proxy-protocol`): for an L4 load balancer (HAProxy `send-proxy`/`send-proxy-v2`, AWS NLB,
Envoy's upstream `proxy_protocol`), the v1 or v2 header gives the client address. It is mandatory on that listener
and believed only from `-trusted` peers; a connection without a valid header, or from any other peer, is closed
without an answer. `LOCAL`/`UNKNOWN` headers (health checks) keep the peer address. Verified with curl's
`--haproxy-protocol` (Β§13).
* Addresses are normalized before they are used as keys: IPv4-mapped IPv6 (`::ffff:1.2.3.4`) is the IPv4 address,
IPv6 is compressed lower-case, zone ids (`fe80::1%eth0`) are dropped, ports are stripped.
* **IPv6 networks.** An IPv6 client usually owns a /64 and can rotate addresses inside it, so by default a sidecar
block on one IPv6 address covers its /64 (`-ipv6-block-prefix 64`, also applied to the HAProxy ACL, which then
holds `2001:db8:1:2::/64`). Rate limits stay per address unless `-ipv6-ratelimit-prefix 64` (then the /64 shares
one standing decision and one token bucket). Challenges stay per address (the cookie is bound to the address).
CIDR entries in the sidecar state (`10.0.0.0/8`, `2001:db8::/48`) are honoured too. Summaries sent to the sidecar
keep the exact address.
* Put the load balancer(s) β€” and only them β€” in `-trusted`: `127.0.0.1/32` when co-located, the ingress pod CIDR in
Kubernetes, the Docker network for a containerised Traefik.
* Each LB below **overwrites or appends** these headers itself (`$proxy_add_x_forwarded_for`, Envoy
`use_remote_address`, Traefik/Caddy rewrite XFF for untrusted clients, HAProxy `set-header`). If the LB itself
sits behind another proxy, configure that LB's own real-IP handling first (nginx `realip`, Envoy
`xff_num_trusted_hops`, Traefik `forwardedHeaders.trustedIPs`, Caddy `trusted_proxies`).
---
## 4. nginx (`auth_request`)
The `auth_request` module is in the official builds (`nginx -V | grep auth_request`). This is the configuration
verified in Β§13.
```nginx
upstream dibaguard_authz { server 127.0.0.1:9300; keepalive 32; }
upstream app { server 127.0.0.1:3000; keepalive 32; }
server {
listen 80;
location / {
auth_request /_dibaguard_auth;
auth_request_set $dg_verdict $upstream_http_x_dibaguard_verdict;
auth_request_set $dg_reason $upstream_http_x_dibaguard_reason;
auth_request_set $dg_status $upstream_status;
error_page 401 = /_dibaguard_challenge; # challenge and rate_limit
error_page 500 = @dibaguard_failopen; # DibaGuard unreachable: serve anyway (remove this line to fail closed)
add_header X-DibaGuard-Verdict $dg_verdict always; # optional, for debugging
proxy_set_header X-DibaGuard-Verdict $dg_verdict; # the app sees the verdict (overwrites a client's)
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://app;
}
location = /_dibaguard_auth {
internal;
proxy_pass http://dibaguard_authz/nginx;
proxy_http_version 1.1; # keep-alive to the verdict service (else one TCP connect per request)
proxy_set_header Connection "";
proxy_pass_request_body off; # auth_request does not pass the body
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 100ms; # > -budget (25ms): the service answers (fail-open) before nginx gives up
proxy_send_timeout 200ms;
proxy_read_timeout 200ms;
}
location = /_dibaguard_challenge {
internal;
proxy_pass http://dibaguard_authz/challenge;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-DibaGuard-Verdict $dg_verdict; # challenge -> JS page, rate_limit -> 429
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
location @dibaguard_failopen {
# Fail open ONLY when DibaGuard could not be reached (502 refused / 504 timed out). An answer it did give is
# not a pass: Go's HTTP server answers 400 to a header nginx accepts but HTTP forbids (a 0x01 byte), and
# nginx turns that into a 500 - without this guard such a request reaches the app unchecked (shown in Β§13).
if ($dg_status !~ "^(502|504)") { return 403; }
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://app;
}
}
```
Run the service with `-trusted 127.0.0.1/32` (nginx's address as the service sees it). `auth_request_set` variables
survive the `error_page` redirect (verified). Log the verdict with
`log_format dg '$remote_addr "$request" $status verdict=$dg_verdict reason=$dg_reason';`.
---
## 5. Kubernetes ingress-nginx
ingress-nginx's external auth is `auth_request` underneath (2xx / 401 / 403 only). It sends `X-Original-URL`
(absolute), `X-Original-Method`, `X-Real-IP` and `X-Forwarded-For` plus the client's headers; `/nginx` reads all of
them. A 401 is turned into a redirect to `auth-signin`, which is pointed at the challenge page.
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
annotations:
nginx.ingress.kubernetes.io/auth-url: "http://dibaguard-authz.shop.svc.cluster.local:9300/nginx"
nginx.ingress.kubernetes.io/auth-response-headers: "X-DibaGuard-Verdict,X-DibaGuard-Score,X-DibaGuard-Reason"
# challenge / rate_limit (401): send the browser to the challenge page, which comes back to the original path
nginx.ingress.kubernetes.io/auth-signin: "https://$host/_dibaguard/challenge?rd=$escaped_request_uri"
spec:
ingressClassName: nginx
rules:
- host: shop.example
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: shop, port: { number: 80 } } }
---
# the challenge page, served by dibaguard-authz on the same host, NOT behind auth-url
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-dibaguard-challenge
spec:
ingressClassName: nginx
rules:
- host: shop.example
http:
paths:
- path: /_dibaguard/challenge
pathType: Exact
backend: { service: { name: dibaguard-authz, port: { number: 9300 } } }
```
* Run `dibaguard-authz` with `-trusted <pod CIDR>` (e.g. `10.244.0.0/16`): the requests come from the controller
pods. Ingress backends must be in the Ingress's namespace β€” deploy the service there (or use an `ExternalName`).
* All ingresses at once: the controller ConfigMap's `global-auth-url` (and `global-auth-signin`).
* For a rate-limited client the challenge page answers 429 `slow down` (it looks the client up in the sidecar state).
* ingress-nginx has no fail-open switch: if the service is unreachable the request gets a 500. Run 2+ replicas with
a PodDisruptionBudget and the same `-challenge-secret` (a Secret mounted in every replica).
---
## 6. Envoy (`ext_authz`, HTTP service)
```yaml
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3
failure_mode_allow: true # DibaGuard unreachable / timed out: let the request through
failure_mode_allow_header_add: true # ... and mark it x-envoy-auth-failure-mode-allowed: true
with_request_body: # optional: Envoy (unlike nginx) can show DibaGuard the body
max_request_bytes: 8192
allow_partial_message: true
allowed_headers: # what the decision reads (older Envoy: http_service.authorization_request.allowed_headers)
patterns:
- { exact: user-agent }
- { exact: cookie }
- { exact: content-type }
- { exact: accept }
- { exact: referer }
- { exact: x-forwarded-for }
- { exact: authorization }
- { exact: x-api-version }
- { exact: origin }
http_service:
server_uri:
uri: http://127.0.0.1:9300
cluster: dibaguard_authz
timeout: 0.1s
path_prefix: /envoy # = dibaguard-authz -envoy-prefix
authorization_response:
allowed_upstream_headers: # on allow: verdict and score to the upstream request (overwrites)
patterns: [ { prefix: x-dibaguard } ]
allowed_client_headers: # on deny: what the client gets besides status and body
patterns:
- { exact: content-type }
- { exact: cache-control }
- { exact: retry-after }
- { prefix: x-dibaguard }
- name: envoy.filters.http.router
typed_config: { "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router }
# clusters:
- name: dibaguard_authz
type: STATIC
connect_timeout: 0.05s
load_assignment:
cluster_name: dibaguard_authz
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: 127.0.0.1, port_value: 9300 } } }
```
* The check request keeps the original method and is sent to `path_prefix + original path`
(`/envoy/items?id=1`). The service strips the prefix from the raw request target β€” `..` segments reach the
decision as sent, no path cleaning or redirect.
* Only **200** allows; any other status, with the allowed headers and the body (the challenge page, `slow down`),
goes to the client. A **5xx** from the service counts as an error, which `failure_mode_allow` lets through β€” so the
service never answers 5xx to Envoy (its fail-closed deny is a 403).
* Client address: with `use_remote_address: true` on the HTTP connection manager Envoy appends the downstream
address to `x-forwarded-for`; trust Envoy's own address (`-trusted 127.0.0.1/32` when co-located).
---
## 7. Traefik (`ForwardAuth`)
```yaml
# dynamic configuration
http:
middlewares:
dibaguard:
forwardAuth:
address: "http://127.0.0.1:9300/forward"
trustForwardHeader: false # true only if Traefik itself is behind a trusted proxy
authResponseHeaders: ["X-DibaGuard-Verdict", "X-DibaGuard-Score", "X-DibaGuard-Reason"]
routers:
shop:
rule: "Host(`shop.example`)"
middlewares: ["dibaguard"]
service: shop
services:
shop:
loadBalancer:
servers: [ { url: "http://127.0.0.1:3000" } ]
```
Docker labels: `traefik.http.middlewares.dibaguard.forwardauth.address=http://dibaguard-authz:9300/forward`,
`traefik.http.middlewares.dibaguard.forwardauth.authResponseHeaders=X-DibaGuard-Verdict,X-DibaGuard-Score`,
`traefik.http.routers.shop.middlewares=dibaguard`. Kubernetes: a `traefik.io/v1alpha1` `Middleware` with the same
`forwardAuth` block.
Traefik sends `X-Forwarded-Method/-Uri/-Host/-Proto/-For` and all original headers; on a non-2xx it returns the
service's response (status, headers, body β€” so the challenge page works as is). Trust Traefik's address
(`-trusted 172.16.0.0/12` for a Docker network). There is no fail-open switch: an unreachable service is an error
for the client β€” run replicas.
---
## 8. Caddy (`forward_auth`)
```caddyfile
shop.example {
forward_auth 127.0.0.1:9300 {
uri /forward
copy_headers X-DibaGuard-Verdict X-DibaGuard-Score X-DibaGuard-Reason
}
reverse_proxy 127.0.0.1:3000
}
```
`forward_auth` sends `X-Forwarded-Method`, `X-Forwarded-Uri`, `X-Forwarded-For` (Caddy ignores a client's XFF unless
the client is in `trusted_proxies`), `X-Forwarded-Host/-Proto` and the original headers. On 2xx the `copy_headers`
are set on the proxied request; anything else is returned to the client as the service sent it. No fail-open
switch (an unreachable service is a 502) β€” run replicas or several upstreams in `forward_auth`.
---
## 9. HAProxy
### 9a. Blocklist in HAProxy's own data path (Runtime API sync)
```haproxy
global
stats socket ipv4@127.0.0.1:9999 level admin # or: stats socket /run/haproxy/admin.sock mode 600 level admin
stats timeout 30s
frontend web
bind :80
# sources the DibaGuard sidecar blocked: refused at accept, before any HTTP parsing; zero per-request round trip
tcp-request connection reject if { src -f /etc/haproxy/dibaguard-block.acl }
# behind another proxy use the address it saw instead:
# http-request deny if { req.hdr_ip(x-forwarded-for,-1) -f /etc/haproxy/dibaguard-block.acl }
default_backend app
```
```sh
touch /etc/haproxy/dibaguard-block.acl # must exist (may stay empty) when HAProxy starts
dibaguard-authz -listen "" -sidecar http://127.0.0.1:8787 \
-haproxy-socket 127.0.0.1:9999 -haproxy-file /etc/haproxy/dibaguard-block.acl
```
* The ACL is **owned by the syncer**: entries it holds that are not in the sidecar's block set are deleted. Use a
dedicated file; put hand-made blocks in another ACL.
* Only the difference is sent (`add acl` / `del acl`; `set map` when a map value changes); additions go first.
* Deadlines are applied by the syncer: an entry leaves HAProxy when its deadline passes, even if the sidecar is down.
* On every (re)connect, every `-haproxy-resync`, and whenever the connection is found closed (stats timeout,
reload β€” checked before each sync without sending anything), it re-reads what HAProxy holds (`show acl`) and
diffs against that. A reload resets the ACL to the (empty) file on disk; it is repaired on the next sync.
* Keys are validated as IP addresses / CIDRs before they reach the socket (IPv4-mapped IPv6 is unmapped); nothing the
sidecar sends can inject a Runtime API command. With `-ipv6-block-prefix 64` (default) an IPv6 block is pushed as
its /64, one entry per network.
* Map variant, one table for all standing decisions:
```haproxy
http-request deny deny_status 403 if { src,map_ip(/etc/haproxy/dibaguard.map) -m str block }
# e.g. send challenged / rate-limited sources through the verdict service (9b), let others skip it
```
`-haproxy-kind map -haproxy-file /etc/haproxy/dibaguard.map -haproxy-actions block,challenge,rate_limit`
(values: `block` > `challenge` > `rate_limit`). Match the value β€” `-m found` would also drop rate-limited sources.
### 9b. Per-request verdict (lua auth-request)
With [haproxy-lua-http](https://github.com/haproxytech/haproxy-lua-http) and `auth-request.lua`
(TimWolla/haproxy-auth-request). The lua action calls the auth backend with `HEAD` and the current request headers,
so the original method/URI/client are set as headers first.
```haproxy
global
lua-prepend-path /usr/share/haproxy/?/http.lua
lua-load /usr/share/haproxy/auth-request.lua
frontend web
bind :80
http-request set-header X-Original-Method %[method]
http-request set-header X-Original-URI %[url]
http-request set-header X-Forwarded-Host %[req.hdr(host)]
http-request set-header X-Forwarded-For %[src] # overwrite whatever the client sent
# fail open when no dibaguard-authz is healthy; otherwise deny everything the service did not allow
http-request lua.auth-request dibaguard_authz /forward if { nbsrv(dibaguard_authz) gt 0 }
http-request deny deny_status 403 if { nbsrv(dibaguard_authz) gt 0 } !{ var(txn.auth_response_successful) -m bool } !{ var(txn.auth_response_code) -m int 429 }
use_backend dibaguard_challenge if { var(txn.auth_response_code) -m int 429 }
default_backend app
backend dibaguard_authz
timeout connect 50ms
timeout server 100ms
server authz1 127.0.0.1:9300 check inter 1s
backend dibaguard_challenge # challenge page or 429 slow down, rendered for this client
http-request set-path /challenge
server authz1 127.0.0.1:9300
backend app
server app1 127.0.0.1:3000
```
Deny on "not successful", not on a list of codes: any answer other than 2xx (a 400 for a malformed header included)
must not pass. The lua action does not return the service's body, hence the `dibaguard_challenge` backend, which
tells a challenge from a rate limit by the sidecar state.
---
## 10. Rolling out: report mode
`-mode report` answers **200 to everything**, even when fail-closed, and puts the would-be verdict in
`X-DibaGuard-Verdict` / `-Reason` (and the summaries the sidecar receives). Log `$dg_verdict` / `$dg_reason` (nginx),
`%[var(...)]` (HAProxy) or the upstream request headers (Envoy/Traefik/Caddy) for a few days, check the `block` and
`challenge` lines, then switch to `-mode enforce`. In report mode the rate-limit bucket is consumed as in enforce
(shadow evaluation), so `rate_limit` verdicts are what enforce would return.
---
## 11. Latency budget
Measured 2026-10-31 on an Intel i3-12100F (4C/8T), Windows 11, Go 1.23.4, with the test-fixture HTTP model, while
the machine carried ~65 % CPU load from other work β€” read these as upper bounds.
In process (`go test -bench`, one decision, no network):
| path | ns/op | allocs |
|---|---|---|
| `Decide` benign (`/product/42?page=2&sort=price`) | 24 000 – 29 700 | 37 (16.6 KB) |
| `Decide` benign Persian query | 21 800 – 24 400 | 35 |
| `Decide` SQLi (`1' union select ...`) | 92 000 – 125 000 | 34 |
| `Decide` Log4Shell header | 104 000 – 113 000 | 34 |
| `Decide`, sidecar-blocked source (no scoring) | 21 – 24 | 0 |
| ↳ of which `MatchSigs` benign / attack | 1 900 – 2 200 / 54 000 – 69 000 | |
| ↳ of which HTTP model `Logits` | 10 400 – 11 800 | |
| full `/nginx` handler, benign (identity, reconstruction, budgeted decision, headers) | 31 000 – 36 000 | 65 |
| full `/nginx` handler, SQLi | 113 000 – 124 000 | 73 |
An attack costs more than a benign request because the signature prefilter lets it through to all nine regex
families; a source the sidecar already blocked costs nothing.
Over the network (real nginx 1.30.6, Windows loopback, keep-alive to the service and the app, 500 sequential
requests per row, two rounds, client-side `time_total`):
| path | p50 | p90 | p99 |
|---|---|---|---|
| nginx β†’ app, no DibaGuard | 116 – 126 Β΅s | 174 – 180 Β΅s | 232 – 257 Β΅s |
| nginx + `auth_request` β†’ app, benign | 242 – 246 Β΅s | 320 – 329 Β΅s | 426 – 602 Β΅s |
| nginx + `auth_request`, SQLi refused (403) | 233 – 236 Β΅s | 320 – 328 Β΅s | 468 – 470 Β΅s |
| `dibaguard-authz` alone over HTTP, benign / SQLi | 77 / 118 – 123 Β΅s | 142 – 148 / 194 – 199 Β΅s | 227 – 244 / 296 – 374 Β΅s |
So `auth_request` adds **~120–130 Β΅s at p50** and ~150 Β΅s at p90 on this machine. Budget for the LB side:
connect timeout 50–100 ms, read timeout 100–200 ms, well above the service's own `-budget 25ms` so that the service
answers (with its fail mode) before the LB gives up. The HAProxy ACL path costs nothing per request; a new block
reaches HAProxy within poll + sync interval (≀ ~4 s with the defaults).
---
## 12. Failure modes
| failure | default (fail-open) | `-fail-closed` |
|---|---|---|
| model file missing / unreadable at start | signatures and sidecar state still enforced; everything else allowed, `X-DibaGuard-Degraded: model_unavailable` | every request denied, `fail_closed:model_unavailable` |
| sidecar down, or state older than `-state-stale` | last known state kept (its deadlines still apply), decisions continue, `Degraded: sidecar_unavailable` | every request denied |
| decision over `-budget` (body read included) | allowed, `fail_open:timeout`, answered at the budget | denied, `fail_closed:timeout` |
| no decision slot within the budget (`-max-inflight`) | allowed, `fail_open:overload` | denied |
| panic inside a decision | allowed, `fail_open:panic` | denied |
| report mode | never denies | never denies |
| service unreachable β€” nginx | 500 unless `error_page 500 = @dibaguard_failopen` **with the `$dg_status` guard** (Β§4) | drop the `error_page 500` line |
| service unreachable β€” Envoy | `failure_mode_allow: true` | `failure_mode_allow: false` |
| service unreachable β€” Traefik / Caddy / ingress-nginx | error to the client (no switch) | same |
| service unreachable β€” HAProxy lua | skipped while `nbsrv(dibaguard_authz)` is 0 (health check) | drop the `if { nbsrv ... }` conditions |
| HAProxy Runtime API unreachable | syncer retries every interval, logs each new error once; **HAProxy keeps its current ACL** (entries do not expire inside HAProxy while the syncer is away) | same |
| HAProxy reload | ACL back to the file on disk until the next sync detects the closed connection and resyncs | same |
Fail-open trades availability for coverage: a flood that pushes decisions past the budget, or an outage, lets
requests through. Pick `-fail-closed` where an unchecked request is worse than a refused one.
---
## 13. Verified on real software (nginx, dibaguard-proxy)
nginx 1.30.6 (`nginx.org` Windows zip, PGP signature checked), the Β§4 configuration (plus debug headers and extra
server blocks for report mode, a dead service and the naive fail-open), `dibaguard-authz` in enforce
and report mode, a fake sidecar (block 127.0.0.3, rate-limit 127.0.0.4, challenge 127.0.0.5) and clients bound to
distinct loopback addresses:
| request | result |
|---|---|
| benign GET, Persian query, POST form | 200, verdict allow |
| SQLi, UNION SELECT, double-encoded SQLi, XSS, traversal, command injection, Log4Shell header | 403, `sig:A15/A19/A17/A18/A08` |
| `/.git/config` | 429 challenge page (via `error_page 401`); with its cookie 200; the same cookie from another address 429 |
| sidecar-blocked source; the same source sending `X-Forwarded-For: 1.2.3.4` | 403 `sidecar:block` both |
| another source sending `X-Forwarded-For: 127.0.0.3` (the blocked one) | 200 β€” identity not taken over; the sidecar summary is attributed to the real sender |
| sidecar-challenged source; with its cookie | 429 challenge; 200 |
| rate-limited source, 4 quick requests (1 r/s, burst 2) | 200, 200, 429 `slow down`, 429 |
| report mode: SQLi / blocked source / probe | 200 with verdict `block` / `block` / `challenge` |
| service down, guarded fail-open / no `error_page` | 200 from the app / 500 |
| SQLi with a `0x01` byte in `User-Agent` | guarded config 403; the naive `error_page 500 = @app` served the SQLi to a lenient app (200) |
Second round, after the decoding and identity changes β€” through nginx + `dibaguard-authz` and directly against two
`dibaguard-proxy` instances (`-trusted 127.0.0.1/32`, one with `-proxy-protocol`):
| request | result |
|---|---|
| `?id=%zz1'or 1=1-- -`, `?host=127.0.0.1;cat /etc/passwd`, `%u0027`-encoded SQLi, double encoding + bad escape (nginx) | 403 each |
| the same through the proxy, and a POST body `user=admin%27+or+1%3D1--+-&x=%zz` | 403 each |
| `?q=100%`, `?q=50%+off&sort=price;desc` (nginx and proxy) | 200 |
| proxy: sidecar-blocked 127.0.0.3 sending `X-Forwarded-For: 1.2.3.4` | 403 (cannot escape) |
| proxy: 127.0.0.2 sending `X-Forwarded-For: 127.0.0.3` | 200 (cannot frame; summary attributed to 127.0.0.2) |
| proxy: trusted 127.0.0.1 sending `127.0.0.3` / `127.0.0.3, 9.9.9.9` | 403 / 200 (client 9.9.9.9) |
| proxy + PROXY v1 (curl `--haproxy-protocol --haproxy-clientip`): client 127.0.0.3 / 203.0.113.7 / 203.0.113.7 with `X-Forwarded-For: 127.0.0.3` / 203.0.113.7 SQLi | 403 / 200 / 200 / 403 |
| proxy + PROXY: no header from the trusted peer; a header from untrusted 127.0.0.2 | connection closed without an answer, both |
---
## 14. Limits and notes
* **Bodies.** nginx `auth_request`, Traefik, Caddy and HAProxy lua do not send the body, so body payloads are not
inspected through them (the URI, query and headers are). Envoy `with_request_body` and the inline proxy do inspect
the body prefix.
* **Upstream status.** An authorization check never sees the app's reply, so the summaries sent to the sidecar
carry status 0 (403 for content blocks). The sidecar's failure-share signals (credential stuffing, enumeration by
4xx) need the inline proxy or the LB's access logs.
* **Challenge cookie.** Bound to the client address and the UTC day; all replicas must share `-challenge-secret`. It
is a JS check (stops clients that do not run JS), not a CAPTCHA.
* **What the app may trust.** Only `X-DibaGuard-*` headers the LB set (nginx `proxy_set_header`, Envoy
`allowed_upstream_headers`, Traefik `authResponseHeaders`, Caddy `copy_headers` all overwrite a client's value).
* **Header size.** The service accepts headers up to Go's 1 MB default, more than any of these LBs forwards by
default, so an oversized request gets a verdict rather than a 431.
* **Canonical text vs. the Python canonicaliser.** Percent decoding of malformed escapes now matches
`dibaguard/http/canon.py` (checked on 20 malformed / mixed / `%u` / double-encoded strings: identical). The Go fast
path still differs from `parse.py`'s `model_text` in ways that predate this work: it does not apply the HTML-entity,
`\xHH` / `\uXXXX`, NFKC, SQL-comment and whitespace layers; it keeps invalid UTF-8 bytes where Python writes
U+FFFD; `+` becomes a space in every decoding round (Python: the first only, so `%2B` ends as `+` there and as a
space here); the first decoding of a query pair is not counted in `deepest`; query keys are sorted; cookies are one
`h:cookie` field (Python: `c:` pairs); the header set differs (Go also reads `accept`, `authorization`,
`x-api-version`, `origin`; Python also `x-requested-with`).