feat(backend): per-policy DDoS rate limiting and connection throttling#274
Conversation
…tling Add DDoS protection knobs (rate limit, window, max connections per IP) to the Policy model, exposed through the existing policy API and admin form. The HAProxy config generator emits a per-vhost stick-table with http_req_rate/conn_cur tracking and 429 denies when a policy has DDoS protection enabled, plus a global slow-loris timeout hardening default. Related to #176
There was a problem hiding this comment.
Pull request overview
This PR adds per-policy DDoS mitigation controls (request-rate limiting + per-IP concurrent connection throttling) and threads them through the backend policy model/API, HAProxy config generation/template rendering, and the admin UI/tests.
Changes:
- Extend
Policywith DDoS-related fields (enabled + thresholds), including DB constraints + Alembic migration, and expose them via create/update endpoints and response schema. - Introduce
HaproxyDdosrender context and emit per-vhost HAProxy stick-tables + 429 deny rules when enabled; addtimeout http-request 5sfor slow-loris hardening. - Update frontend policy form + types and add/adjust unit/integration/UI tests to cover the new fields.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/frontend/src/pages/vhosts/VHostDetailPage.test.tsx | Extend mocked vhost policy data with new DDoS fields. |
| src/frontend/src/pages/policies/PolicyDetailPage.test.tsx | Extend mocked policy detail data with new DDoS fields. |
| src/frontend/src/pages/policies/PoliciesPage.test.tsx | Extend mocked policy list data with new DDoS fields. |
| src/frontend/src/features/vhosts/types.ts | Add DDoS fields to the assigned-policy type used by vhosts. |
| src/frontend/src/features/policies/types.ts | Add DDoS fields to policy types (read/create/update). |
| src/frontend/src/features/policies/PolicyFormModal.tsx | Add UI controls/state and submit wiring for DDoS settings. |
| src/frontend/src/features/policies/PolicyFormModal.test.tsx | Add UI tests for DDoS toggle + field enablement + API payload. |
| src/backend/tests/unit/test_policy_service.py | Add unit tests for persisting/updating DDoS policy fields. |
| src/backend/tests/unit/test_config_renderer_haproxy.py | Add unit tests asserting HAProxy template output with/without DDoS blocks and validation. |
| src/backend/tests/unit/test_config_generator.py | Add generator tests for DDoS wiring and haproxy -c validation with DDoS enabled. |
| src/backend/tests/integration/test_policies_router.py | Add integration tests covering defaulting/round-tripping/validation for DDoS policy fields. |
| src/backend/app/templates/haproxy.cfg.j2 | Emit per-vhost stick-table tracking + 429 denies and add timeout http-request 5s. |
| src/backend/app/services/policy_service.py | Allow create/patch for new DDoS fields via service layer. |
| src/backend/app/services/config_renderer.py | Add HaproxyDdos dataclass + uniqueness validation for ddos table names. |
| src/backend/app/services/config_generator.py | Thread vhost policy into HAProxy render context and build HaproxyDdos when enabled. |
| src/backend/app/schemas/policy.py | Add DDoS fields + validation constraints to create/update/response schemas. |
| src/backend/app/routers/policies.py | Pass DDoS fields from request schema into policy creation. |
| src/backend/app/models/policy.py | Add DB columns + check constraints for DDoS policy settings. |
| src/backend/alembic/versions/473d83df7b55_add_ddos_protection_fields_to_policies.py | Migration adding DDoS columns and constraints. |
| configs/haproxy/haproxy.cfg | Update checked-in HAProxy config to include timeout http-request 5s. |
| http-request track-sc0 src table {{ route.ddos.stick_table_name }} if {{ route.vhost_acl_name }} | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } |
There was a problem hiding this comment.
Checked against the HAProxy documentation for http_req_rate(<period>): it returns the count of events observed during the trailing <period>, not a per-second rate (this is the standard stick-table rate-limiting pattern — e.g. store http_req_rate(10s) + gt 100 denies a client making more than 100 requests in the last 10 seconds). So rate_limit_requests is already "requests per rate_limit_window_seconds" as intended, and a 100/10s policy behaves like ~10 req/s sustained, not ~100 req/s. No behavior change needed here; added a clarifying comment in the template to prevent future confusion (5800f42).
| http-request track-sc0 src table {{ route.ddos.stick_table_name }} if {{ route.vhost_acl_name }} | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } | ||
| http-request deny deny_status 429 if {{ route.vhost_acl_name }} { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } |
There was a problem hiding this comment.
Good catch. Fixed by adding !is_health !is_acme to both the track-sc0 and 429-deny conditions in fe_http and fe_https, so health checks and ACME HTTP-01 renewals are never throttled or denied by the DDoS stick-table. Updated the corresponding renderer/generator unit tests. (5800f42)
| """Per-vhost DDoS protection settings (rate limiting + connection throttling). | ||
|
|
||
| All fields are validated on construction; ``stick_table_name`` is derived | ||
| from the vhost's existing ACL suffix so it is guaranteed to be a safe and | ||
| unique HAProxy identifier. | ||
| """ |
There was a problem hiding this comment.
Reworded — the docstring no longer claims uniqueness/derivation is enforced by this class; it now points to the config generator and HaproxyRenderContext's uniqueness check as the actual owners of that invariant. (5800f42)
Health checks and Let's Encrypt HTTP-01 challenges could previously be starved by the same stick-table limits applied to regular traffic on a vhost. Exclude is_health and is_acme from DDoS tracking and denies, and clarify the HaproxyDdos docstring about which layer owns table-name uniqueness.
Summary
Implements the MVP core of #176 (DDoS protection): per-source-IP request-rate limiting, connection throttling, and slow-loris timeout hardening, configurable per WAF policy.
ddos_protection_enabled,rate_limit_requests,rate_limit_window_seconds,max_connections_per_ipcolumns toPolicy(Alembic migration included).HaproxyDdosrender context; the HAProxy template emits a dedicated per-vhoststick-table(http_req_rate+conn_cur) and429denies when enabled, and nothing at all when disabled (byte-identical config for existing policies).timeout http-request 5sglobally for slow-loris protection.Scope
This PR intentionally covers only the config-only core. The remaining scope of #176 is split into follow-up issues so this stays reviewable:
Related to #176
Test plan
uv run pytest --cov=app— 547 passed (added unit tests forHaproxyDdosvalidation/rendering, config generator DDoS wiring, policy service, and integration tests for the policy API)uv run mypy app/— cleanuv run ruff check app/— cleanpnpm run type-check— cleanpnpm run lint— cleanpnpm test— 111 passed (newPolicyFormModaltests for the DDoS fields)haproxy -cvalidates the generated config both with and without DDoS protection enabled (asserted in tests)