Download docs/INTEGRATIONS.md from Dibachain/DibaGuard: direct link, hf CLI and curl.
- Browser
- Download file 36.6 kB
-
https://huggingface.co/Dibachain/DibaGuard/resolve/main/docs/INTEGRATIONS.md
- Command line
-
hf download hf://Dibachain/DibaGuard/docs/INTEGRATIONS.md
-
curl -L -o INTEGRATIONS.md https://huggingface.co/Dibachain/DibaGuard/resolve/main/docs/INTEGRATIONS.md
DibaGuard β load-balancer integrations
Open-release copy (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 inCLEAN_MODELS.md. Licence statements inside this document are superseded byDATA_LICENSES.md,CLEAN_MODELS.mdand the repository'sLICENSE,LICENSE-WEIGHTSandNOTICEfiles. 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:
- Verdict service β
dibaguard-authzanswers "may this request pass?" for nginxauth_request, ingress-nginxauth-url, Envoyext_authz, TraefikForwardAuth, Caddyforward_authand 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 fromguard_fast_state), the payload signatures (MatchSigs) and the site-conditioned HTTP model. - 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
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-ForandForwarded(RFC 7239, itsfor=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 withoutfor=, garbage) stops the walk.X-Real-IPholds one address.- List only headers your load balancer sets or overwrites in
-ip-headers(defaultX-Forwarded-For). A header the LB passes through untouched is written by the client: most LBs passForwardedandX-Real-IPthrough unless configured to set them, so they are opt-in. - PROXY protocol (
-proxy-protocol): for an L4 load balancer (HAProxysend-proxy/send-proxy-v2, AWS NLB, Envoy's upstreamproxy_protocol), the v1 or v2 header gives the client address. It is mandatory on that listener and believed only from-trustedpeers; a connection without a valid header, or from any other peer, is closed without an answer.LOCAL/UNKNOWNheaders (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 holds2001: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/32when 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, Envoyuse_remote_address, Traefik/Caddy rewrite XFF for untrusted clients, HAProxyset-header). If the LB itself sits behind another proxy, configure that LB's own real-IP handling first (nginxrealip, Envoyxff_num_trusted_hops, TraefikforwardedHeaders.trustedIPs, Caddytrusted_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.
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.
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-authzwith-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 anExternalName). - All ingresses at once: the controller ConfigMap's
global-auth-url(andglobal-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)
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, whichfailure_mode_allowlets through β so the service never answers 5xx to Envoy (its fail-closed deny is a 403). - Client address: with
use_remote_address: trueon the HTTP connection manager Envoy appends the downstream address tox-forwarded-for; trust Envoy's own address (-trusted 127.0.0.1/32when co-located).
7. Traefik (ForwardAuth)
# 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)
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)
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
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 mapwhen 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:
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 foundwould also drop rate-limited sources.
9b. Per-request verdict (lua auth-request)
With 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.
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). Envoywith_request_bodyand 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 (nginxproxy_set_header, Envoyallowed_upstream_headers, TraefikauthResponseHeaders, Caddycopy_headersall 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 fromparse.py'smodel_textin 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%2Bends as+there and as a space here); the first decoding of a query pair is not counted indeepest; query keys are sorted; cookies are oneh:cookiefield (Python:c:pairs); the header set differs (Go also readsaccept,authorization,x-api-version,origin; Python alsox-requested-with).