Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions configs/haproxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# HAProxy reference configuration

This directory contains the hand-written reference configuration that
HAProxy uses to consult the Coraza SPOA WAF. It is intentionally small
and readable: M2 (#110) will replace it with Jinja2 templates rendered
from the policy database, so this file is the seed those templates
must reproduce.

## Files

| File | Purpose |
|--------------|----------------------------------------------------------|
| `haproxy.cfg`| Frontend, vhost ACL, SPOE filter, backends |
| `coraza.cfg` | SPOE engine + message definition for the Coraza SPOA |
| `README.md` | This document |
Comment on lines +11 to +15

## Request flow

```
Client ──► HAProxy :80 ──► (SPOE) ──► Coraza SPOA :9000
▲ │
│ ▼
└──── allow / deny ◄── decision
│
▼ (if allowed)
Backend app.local ──► be_app (backend:8000)
```

1. The client sends an HTTP request to HAProxy on port 80.
2. The `fe_http` frontend stamps it with `X-Request-ID` and matches
the `Host` header against the `host_app` ACL. Anything that is not
`app.local` is rejected with `421 Misdirected Request`.
3. The `spoe` filter sends a `coraza-req` message to the
`coraza-spoa` backend (TCP, `coraza:9000`).
4. The SPOA evaluates the request against Coraza/CRS rules and
returns variables under `txn.coraza.*`.
5. If `txn.coraza.action == "deny"`, HAProxy responds with
`403 Forbidden` and never contacts the backend.
6. Otherwise the request is routed to `be_app`, which forwards to
`backend:8000` (the FastAPI service in Docker Compose).

## SPOE variables

All variables set by the SPOA are namespaced via
`option var-prefix coraza`, so HAProxy sees them as `txn.coraza.<name>`.

| Variable | Meaning |
|----------------------|--------------------------------------------------------|
| `txn.coraza.action` | Decision string (`deny` blocks; anything else allows) |
| `txn.coraza.score` | Anomaly score from the rule set, propagated as header |
| `txn.coraza.id` | Transaction id correlated with HAProxy's `unique-id` |

Comment on lines +47 to +52

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After reviewing the file, all three tables in configs/haproxy/README.md already use single | row starts — there are no || double-pipe prefixes in the file. The automated reviewer comment appears to have been a false positive. No changes are needed.

The exact set of variables produced depends on the Coraza SPOA
version and rule configuration; the three above are the minimum this
reference relies on.

## SPOE message arguments

`coraza-req` is emitted on `on-frontend-http-request` and carries the
data Coraza needs to run request-phase rules:

| Argument | HAProxy fetch | Notes |
|------------|------------------|----------------------------------------|
| `app` | `str(default)` | Which Coraza application bundle to use |
| `id` | `unique-id` | Same value as the `X-Request-ID` header|
| `src-ip` | `src` | Client IP |
| `src-port` | `src_port` | Client TCP port |
| `dst-ip` | `dst` | HAProxy bind IP |
| `dst-port` | `dst_port` | HAProxy bind port |
| `method` | `method` | HTTP method |
| `path` | `path` | Request path without query |
| `query` | `query` | Raw query string |
| `version` | `req.ver` | HTTP version |
| `headers` | `req.hdrs` | All request headers, framed for SPOE |
| `body` | `req.body` | Request body (subject to SPOA limits) |
Comment on lines +62 to +75

Response-phase inspection is deliberately out of scope for M1
(see ADR-007).

## Failure behaviour

`spoe-agent` is configured with `option set-on-error error`. If the
SPOA is unreachable or returns an error, `txn.coraza.action` will not
equal `"deny"` and the request is forwarded — i.e. the proxy
fail-opens. Hardening this into an explicit degraded mode is tracked
in #80; M1 only needs the happy path.

## Validating the config

The configuration is exercised in two ways:

1. Syntax / semantic check (no runtime needed):

```sh
haproxy -c -f configs/haproxy/haproxy.cfg
```

If `haproxy` is not installed locally, run the same check inside
the pinned image:

```sh
docker run --rm \
-v "$PWD/configs/haproxy:/usr/local/etc/haproxy:ro" \
haproxy:3.0-alpine \
haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg
```

The check must report `Configuration file is valid` with no
warnings.

2. End-to-end smoke test against a running HAProxy + Coraza SPOA
pair. This is the responsibility of #107 (compose wiring) and
#108 (smoke test): a benign request to `app.local` reaches the
backend, while a known SQL-injection payload is answered with
`403 Forbidden`.

## Relationship to other issues

- ADR-007 — picks upstream `coraza-spoa` as the SPOA implementation.
- #106 — provides the Coraza SPOA + OWASP CRS bundle this config
talks to.
- #107 — wires this `configs/haproxy/` directory into Docker Compose.
- #108 — runs the end-to-end smoke test that this reference unblocks.
- #110 — replaces these files with Jinja2 templates seeded from this
reference.
39 changes: 39 additions & 0 deletions configs/haproxy/coraza.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# SPOE engine configuration for the Coraza SPOA.
#
# This file is referenced from haproxy.cfg via:
# filter spoe engine coraza config /usr/local/etc/haproxy/coraza.cfg
#
# Section name "[coraza]" must match the engine name passed to the
# filter directive. The SPOA itself is reached through the
# "coraza-spoa" backend defined in haproxy.cfg.

[coraza]

spoe-agent coraza-agent
# Messages this agent is allowed to send.
messages coraza-req

# All variables produced by the agent are exposed under
# txn.coraza.* (e.g. txn.coraza.action, txn.coraza.score).
option var-prefix coraza

# If the SPOA is unreachable or replies with an error, mark the
# transaction as errored. haproxy.cfg currently fail-opens in
# that case; failure handling is tracked separately in #80.
option set-on-error error

# SPOE handshake / idle / per-request timeouts. Keep processing
# tight so a slow WAF cannot block the request path indefinitely.
timeout hello 2s
timeout idle 2m
timeout processing 500ms

use-backend coraza-spoa
log global

# Sent once per HTTP request, before the backend selection. The
# argument names on the right hand side are HAProxy fetches; the
# names on the left hand side are what the Coraza SPOA expects.
spoe-message coraza-req
args app=str(default) id=unique-id src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body
event on-frontend-http-request
Comment on lines +34 to +39
81 changes: 81 additions & 0 deletions configs/haproxy/haproxy.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Reference HAProxy configuration for Guard Proxy M1.
#
# Goals:
# - Single frontend listening on :80
# - Single virtual host routed to a single backend
# - SPOE filter forwarding request metadata to Coraza SPOA
# - Block requests when Coraza returns action=deny
#
# This file is the hand-written seed that the M2 Jinja2 template
# generator will later reproduce from the policy database.

Comment on lines +9 to +11
global
log stdout format raw local0 info
maxconn 2000
# The SPOE filter expects a writable runtime directory.
# Use operator level to avoid exposing administrative Runtime API
# commands via the local socket.
stats socket /var/run/haproxy.sock mode 660 level operator

defaults
mode http
log global
option httplog
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s

# --- Public frontend ----------------------------------------------
frontend fe_http
bind *:80
# Tag every request with a stable id so log lines and SPOE frames
# can be correlated.
unique-id-format %[uuid()]
unique-id-header X-Request-ID

# Hand the request off to the Coraza SPOA. The engine name
# ("coraza") must match the [coraza] section in coraza.cfg.
filter spoe engine coraza config /usr/local/etc/haproxy/coraza.cfg

# Reference vhost: only requests for app.local are accepted.
# Anything else is rejected before it hits the backend.
acl host_app hdr(host) -i app.local app.local:80
http-request deny deny_status 421 if !host_app

# Coraza populates txn.coraza.* via the SPOE response. If the
# engine returns action=deny we stop the transaction with 403.
http-request deny deny_status 403 if { var(txn.coraza.action) -m str deny }

# Optional: log the WAF anomaly score on allowed requests so it
# shows up in the access log for debugging.
http-request set-header X-WAF-Score %[var(txn.coraza.score)] if { var(txn.coraza.score) -m found }

use_backend be_app if host_app
default_backend be_app

# --- Application backend (single vhost target) -------------------
backend be_app
option forwardfor
option httpchk GET /health
http-check expect status 200
# init-addr none lets `haproxy -c` succeed even when the
# `backend` DNS name is not resolvable (e.g. outside compose).
server app backend:8000 check inter 5s fall 3 rise 2 init-addr last,libc,none

# --- Coraza SPOA backend used by the SPOE filter -----------------
# The SPOA speaks SPOP (binary, request/response) over TCP, so this
# backend runs in mode tcp and must NOT have an HTTP check.
backend coraza-spoa
mode tcp
timeout connect 5s
timeout server 3m
server coraza coraza:9000 check inter 10s fall 3 rise 2 init-addr last,libc,none

# --- Local stats endpoint (dev convenience, not exposed publicly) -
listen stats
bind 127.0.0.1:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
5 changes: 0 additions & 5 deletions deploy/docker/coraza/coraza.conf

This file was deleted.

27 changes: 0 additions & 27 deletions deploy/docker/haproxy/haproxy.cfg

This file was deleted.

Loading