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). 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

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.

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-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)

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)

# 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 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:

    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 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). 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).