From 5fbe1c7bf308efbee5f10b29bfa715435a76bd2e Mon Sep 17 00:00:00 2001 From: monte Date: Tue, 21 Jul 2026 22:29:55 +0200 Subject: [PATCH 1/2] feat(backend): add config-only automatic IP banning for DDoS policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends per-policy DDoS protection with automatic IP banning: repeated rate-limit/connection-limit violations increment a per-source abuse counter in a dedicated stick-table, and the source is denied once it crosses ban_threshold until it stays idle for ban_duration_seconds. - Policy gains auto_ban_enabled, ban_threshold, ban_duration_seconds (only takes effect when ddos_protection_enabled is also true). - HaproxyDdos carries the ban stick-table name and thresholds; the template emits track-sc1 + sc-inc-gpc0 + a gpc0-based deny per vhost, exempting health checks and ACME challenges like the rate/conn rules. - Policy form gains an auto-ban toggle nested under DDoS protection. No live ban-list read/unban yet — that remains a separate follow-up. --- ...2cbacec_add_auto_ban_fields_to_policies.py | 68 +++++++ src/backend/app/models/policy.py | 24 +++ src/backend/app/routers/policies.py | 3 + src/backend/app/schemas/policy.py | 9 + src/backend/app/services/config_generator.py | 4 + src/backend/app/services/config_renderer.py | 22 ++ src/backend/app/services/policy_service.py | 12 ++ src/backend/app/templates/haproxy.cfg.j2 | 21 ++ .../tests/integration/test_policies_router.py | 88 ++++++++ .../tests/unit/test_config_generator.py | 133 ++++++++++++ .../unit/test_config_renderer_haproxy.py | 189 ++++++++++++++++++ src/backend/tests/unit/test_policy_service.py | 57 ++++++ .../policies/PolicyFormModal.test.tsx | 81 ++++++++ .../src/features/policies/PolicyFormModal.tsx | 59 ++++++ src/frontend/src/features/policies/types.ts | 9 + src/frontend/src/features/vhosts/types.ts | 3 + .../src/pages/policies/PoliciesPage.test.tsx | 3 + .../pages/policies/PolicyDetailPage.test.tsx | 3 + .../src/pages/vhosts/VHostDetailPage.test.tsx | 3 + 19 files changed, 791 insertions(+) create mode 100644 src/backend/alembic/versions/ea9292cbacec_add_auto_ban_fields_to_policies.py diff --git a/src/backend/alembic/versions/ea9292cbacec_add_auto_ban_fields_to_policies.py b/src/backend/alembic/versions/ea9292cbacec_add_auto_ban_fields_to_policies.py new file mode 100644 index 0000000..06b409f --- /dev/null +++ b/src/backend/alembic/versions/ea9292cbacec_add_auto_ban_fields_to_policies.py @@ -0,0 +1,68 @@ +"""add auto ban fields to policies + +Revision ID: ea9292cbacec +Revises: 473d83df7b55 +Create Date: 2026-07-21 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "ea9292cbacec" +down_revision: str | Sequence[str] | None = "473d83df7b55" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table("policies") as batch_op: + batch_op.add_column( + sa.Column( + "auto_ban_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + batch_op.add_column( + sa.Column( + "ban_threshold", + sa.Integer(), + nullable=False, + server_default="10", + ) + ) + batch_op.add_column( + sa.Column( + "ban_duration_seconds", + sa.Integer(), + nullable=False, + server_default="600", + ) + ) + batch_op.create_check_constraint( + "ck_policies_ban_threshold", + "ban_threshold >= 1", + ) + batch_op.create_check_constraint( + "ck_policies_ban_duration_seconds", + "ban_duration_seconds BETWEEN 1 AND 86400", + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table("policies") as batch_op: + batch_op.drop_constraint( + "ck_policies_ban_duration_seconds", type_="check" + ) + batch_op.drop_constraint("ck_policies_ban_threshold", type_="check") + batch_op.drop_column("ban_duration_seconds") + batch_op.drop_column("ban_threshold") + batch_op.drop_column("auto_ban_enabled") diff --git a/src/backend/app/models/policy.py b/src/backend/app/models/policy.py index a9f7bee..0b3b4aa 100644 --- a/src/backend/app/models/policy.py +++ b/src/backend/app/models/policy.py @@ -72,6 +72,14 @@ class Policy(Base): "max_connections_per_ip >= 1", name="ck_policies_max_connections_per_ip", ), + CheckConstraint( + "ban_threshold >= 1", + name="ck_policies_ban_threshold", + ), + CheckConstraint( + "ban_duration_seconds BETWEEN 1 AND 86400", + name="ck_policies_ban_duration_seconds", + ), ) id: Mapped[int] = mapped_column(primary_key=True) @@ -129,6 +137,22 @@ class Policy(Base): default=20, # concurrent connection limit per source IP ) + # Automatic IP banning (config-only; see #275). Only takes effect when + # ddos_protection_enabled is also true. + auto_ban_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + ban_threshold: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=10, # rate/conn violations before a source IP is banned + ) + ban_duration_seconds: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=600, # ban duration (idle-expiry), in seconds + ) + # Audit — who created the policy # ForeignKey("users.id") = reference to users table, id column # nullable=True — can be NULL if the user was deleted (soft delete) diff --git a/src/backend/app/routers/policies.py b/src/backend/app/routers/policies.py index f17a94b..91220c8 100644 --- a/src/backend/app/routers/policies.py +++ b/src/backend/app/routers/policies.py @@ -48,6 +48,9 @@ def create_policy( rate_limit_requests=body.rate_limit_requests, rate_limit_window_seconds=body.rate_limit_window_seconds, max_connections_per_ip=body.max_connections_per_ip, + auto_ban_enabled=body.auto_ban_enabled, + ban_threshold=body.ban_threshold, + ban_duration_seconds=body.ban_duration_seconds, created_by=current_user.id, ) except PolicyNameAlreadyExistsError as error: diff --git a/src/backend/app/schemas/policy.py b/src/backend/app/schemas/policy.py index c7d060a..a08ef60 100644 --- a/src/backend/app/schemas/policy.py +++ b/src/backend/app/schemas/policy.py @@ -23,6 +23,9 @@ class PolicyCreate(BaseModel): rate_limit_requests: int = Field(default=100, ge=1) rate_limit_window_seconds: int = Field(default=10, ge=1, le=3600) max_connections_per_ip: int = Field(default=20, ge=1) + auto_ban_enabled: bool = False + ban_threshold: int = Field(default=10, ge=1) + ban_duration_seconds: int = Field(default=600, ge=1, le=86400) @field_validator("inbound_anomaly_threshold", "outbound_anomaly_threshold") @classmethod @@ -50,6 +53,9 @@ class PolicyUpdate(BaseModel): rate_limit_requests: int | None = Field(default=None, ge=1) rate_limit_window_seconds: int | None = Field(default=None, ge=1, le=3600) max_connections_per_ip: int | None = Field(default=None, ge=1) + auto_ban_enabled: bool | None = None + ban_threshold: int | None = Field(default=None, ge=1) + ban_duration_seconds: int | None = Field(default=None, ge=1, le=86400) @field_validator("inbound_anomaly_threshold", "outbound_anomaly_threshold") @classmethod @@ -76,6 +82,9 @@ class PolicyResponse(BaseModel): rate_limit_requests: int rate_limit_window_seconds: int max_connections_per_ip: int + auto_ban_enabled: bool + ban_threshold: int + ban_duration_seconds: int created_by: int | None created_at: datetime updated_at: datetime diff --git a/src/backend/app/services/config_generator.py b/src/backend/app/services/config_generator.py index 69409b5..c1c4880 100644 --- a/src/backend/app/services/config_generator.py +++ b/src/backend/app/services/config_generator.py @@ -238,6 +238,10 @@ def _to_haproxy_context( rate_limit_requests=policy.rate_limit_requests, rate_limit_window_seconds=policy.rate_limit_window_seconds, max_connections_per_ip=policy.max_connections_per_ip, + auto_ban_enabled=policy.auto_ban_enabled, + ban_stick_table_name=f"st_ban_{suffix}", + ban_threshold=policy.ban_threshold, + ban_duration_seconds=policy.ban_duration_seconds, ) if policy is not None and policy.ddos_protection_enabled else None diff --git a/src/backend/app/services/config_renderer.py b/src/backend/app/services/config_renderer.py index 4a35ccf..6cfa36d 100644 --- a/src/backend/app/services/config_renderer.py +++ b/src/backend/app/services/config_renderer.py @@ -156,6 +156,10 @@ class HaproxyDdos: rate_limit_requests: int rate_limit_window_seconds: int max_connections_per_ip: int + auto_ban_enabled: bool = False + ban_stick_table_name: str = "" + ban_threshold: int = 10 + ban_duration_seconds: int = 600 def __post_init__(self) -> None: _validate_haproxy_identifier( @@ -169,6 +173,16 @@ def __post_init__(self) -> None: ) if self.max_connections_per_ip < 1: raise ValueError("HaproxyDdos.max_connections_per_ip must be positive") + if self.auto_ban_enabled: + _validate_haproxy_identifier( + self.ban_stick_table_name, "HaproxyDdos.ban_stick_table_name" + ) + if self.ban_threshold < 1: + raise ValueError("HaproxyDdos.ban_threshold must be positive") + if not 1 <= self.ban_duration_seconds <= 86400: + raise ValueError( + "HaproxyDdos.ban_duration_seconds must be between 1 and 86400" + ) @dataclass(frozen=True) @@ -229,6 +243,14 @@ def __post_init__(self) -> None: ), "HaproxyRenderContext.routes.ddos.stick_table_name", ) + _ensure_unique( + ( + route.ddos.ban_stick_table_name + for route in self.routes + if route.ddos is not None and route.ddos.auto_ban_enabled + ), + "HaproxyRenderContext.routes.ddos.ban_stick_table_name", + ) @dataclass(frozen=True) diff --git a/src/backend/app/services/policy_service.py b/src/backend/app/services/policy_service.py index c89108b..633b942 100644 --- a/src/backend/app/services/policy_service.py +++ b/src/backend/app/services/policy_service.py @@ -18,6 +18,9 @@ "rate_limit_requests", "rate_limit_window_seconds", "max_connections_per_ip", + "auto_ban_enabled", + "ban_threshold", + "ban_duration_seconds", } # description is intentionally included: it is nullable and may be set to None. @@ -33,6 +36,9 @@ "rate_limit_requests", "rate_limit_window_seconds", "max_connections_per_ip", + "auto_ban_enabled", + "ban_threshold", + "ban_duration_seconds", } @@ -97,6 +103,9 @@ def create_policy( rate_limit_requests: int = 100, rate_limit_window_seconds: int = 10, max_connections_per_ip: int = 20, + auto_ban_enabled: bool = False, + ban_threshold: int = 10, + ban_duration_seconds: int = 600, ) -> Policy: """Create and persist a new policy.""" policy = Policy( @@ -110,6 +119,9 @@ def create_policy( rate_limit_requests=rate_limit_requests, rate_limit_window_seconds=rate_limit_window_seconds, max_connections_per_ip=max_connections_per_ip, + auto_ban_enabled=auto_ban_enabled, + ban_threshold=ban_threshold, + ban_duration_seconds=ban_duration_seconds, is_active=True, created_by=created_by, ) diff --git a/src/backend/app/templates/haproxy.cfg.j2 b/src/backend/app/templates/haproxy.cfg.j2 index c294eb5..062df73 100644 --- a/src/backend/app/templates/haproxy.cfg.j2 +++ b/src/backend/app/templates/haproxy.cfg.j2 @@ -65,6 +65,16 @@ frontend fe_http http-request track-sc0 src table {{ route.ddos.stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { 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 }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } +{% if route.ddos.auto_ban_enabled %} + # Automatic IP banning: repeated rate/conn violations trip a ban that + # lifts after ban_duration_seconds of inactivity (track-sc1 refreshes + # the entry's expiry on every request, so a persistent attacker stays + # banned). sc-inc-gpc0 must run before the matching deny rule. + http-request track-sc1 src table {{ route.ddos.ban_stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme + http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_get_gpc0(1) gt {{ route.ddos.ban_threshold }} } + http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } + http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } +{% endif %} {% endif %} {% endfor %} {% endif %} @@ -125,6 +135,12 @@ frontend fe_https http-request track-sc0 src table {{ route.ddos.stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { 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 }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } +{% if route.ddos.auto_ban_enabled %} + http-request track-sc1 src table {{ route.ddos.ban_stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme + http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_get_gpc0(1) gt {{ route.ddos.ban_threshold }} } + http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } + http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } +{% endif %} {% endif %} {% endfor %} {% endif %} @@ -160,6 +176,11 @@ frontend fe_https backend {{ route.ddos.stick_table_name }} stick-table type ipv6 size 100k expire {{ route.ddos.rate_limit_window_seconds }}s store http_req_rate({{ route.ddos.rate_limit_window_seconds }}s),conn_cur +{% if route.ddos.auto_ban_enabled %} +backend {{ route.ddos.ban_stick_table_name }} + stick-table type ipv6 size 100k expire {{ route.ddos.ban_duration_seconds }}s store gpc0 + +{% endif %} {% endif %} {% endfor %} {% endif %} diff --git a/src/backend/tests/integration/test_policies_router.py b/src/backend/tests/integration/test_policies_router.py index 0a03834..2947ff9 100644 --- a/src/backend/tests/integration/test_policies_router.py +++ b/src/backend/tests/integration/test_policies_router.py @@ -135,6 +135,9 @@ def test_create_policy_default_ddos_settings( assert body["rate_limit_requests"] == 100 assert body["rate_limit_window_seconds"] == 10 assert body["max_connections_per_ip"] == 20 + assert body["auto_ban_enabled"] is False + assert body["ban_threshold"] == 10 + assert body["ban_duration_seconds"] == 600 def test_create_policy_with_ddos_settings_returns_201( @@ -164,6 +167,68 @@ def test_create_policy_with_ddos_settings_returns_201( assert body["max_connections_per_ip"] == 10 +def test_create_policy_with_auto_ban_settings_returns_201( + client: TestClient, admin_token: dict[str, str] +) -> None: + """Auto-ban fields round-trip through creation.""" + resp = client.post( + "/policies", + headers=admin_token, + json={ + "name": "Auto-Ban Hardened", + "paranoia_level": 2, + "inbound_anomaly_threshold": 5, + "outbound_anomaly_threshold": 5, + "ddos_protection_enabled": True, + "auto_ban_enabled": True, + "ban_threshold": 3, + "ban_duration_seconds": 300, + }, + ) + + assert resp.status_code == 201 + body = resp.json() + assert body["auto_ban_enabled"] is True + assert body["ban_threshold"] == 3 + assert body["ban_duration_seconds"] == 300 + + +def test_create_policy_invalid_ban_threshold_returns_422( + client: TestClient, admin_token: dict[str, str] +) -> None: + """Out-of-range ban_threshold is rejected at router validation.""" + resp = client.post( + "/policies", + headers=admin_token, + json={ + "name": "Invalid ban threshold", + "paranoia_level": 2, + "inbound_anomaly_threshold": 5, + "outbound_anomaly_threshold": 5, + "ban_threshold": 0, + }, + ) + assert resp.status_code == 422 + + +def test_create_policy_invalid_ban_duration_returns_422( + client: TestClient, admin_token: dict[str, str] +) -> None: + """Out-of-range ban_duration_seconds is rejected at router validation.""" + resp = client.post( + "/policies", + headers=admin_token, + json={ + "name": "Invalid ban duration", + "paranoia_level": 2, + "inbound_anomaly_threshold": 5, + "outbound_anomaly_threshold": 5, + "ban_duration_seconds": 86401, + }, + ) + assert resp.status_code == 422 + + def test_create_policy_invalid_rate_limit_requests_returns_422( client: TestClient, admin_token: dict[str, str] ) -> None: @@ -362,6 +427,29 @@ def test_patch_policy_updates_ddos_settings( assert body["max_connections_per_ip"] == 40 +def test_patch_policy_updates_auto_ban_settings( + client: TestClient, + admin_token: dict[str, str], +) -> None: + """PATCH updates auto-ban fields.""" + created = _create_policy(client, admin_token, name="Patch Auto-Ban") + + resp = client.patch( + f"/policies/{created['id']}", + headers=admin_token, + json={ + "auto_ban_enabled": True, + "ban_threshold": 7, + "ban_duration_seconds": 1200, + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["auto_ban_enabled"] is True + assert body["ban_threshold"] == 7 + assert body["ban_duration_seconds"] == 1200 + + def test_patch_policy_viewer_forbidden( client: TestClient, admin_token: dict[str, str], diff --git a/src/backend/tests/unit/test_config_generator.py b/src/backend/tests/unit/test_config_generator.py index a962115..6ac414b 100644 --- a/src/backend/tests/unit/test_config_generator.py +++ b/src/backend/tests/unit/test_config_generator.py @@ -363,6 +363,139 @@ def test_generated_haproxy_cfg_with_ddos_validates_with_haproxy( assert result.returncode == 0, result.stderr +def test_generate_one_vhost_with_auto_ban_enabled() -> None: + vhost = VHost( + id=5, + domain="api.example.com", + backend_url="http://api-backend:9000", + is_active=True, + ssl_enabled=False, + policy_id=10, + ) + policy = Policy( + id=10, + name="Strict", + paranoia_level=2, + inbound_anomaly_threshold=5, + outbound_anomaly_threshold=4, + enforcement_mode=PolicyEnforcementMode.block, + is_active=True, + ddos_protection_enabled=True, + rate_limit_requests=50, + rate_limit_window_seconds=5, + max_connections_per_ip=10, + auto_ban_enabled=True, + ban_threshold=3, + ban_duration_seconds=300, + ) + + generated = generate(vhosts=[vhost], policies=[policy], rule_overrides=[]) + + assert "backend st_ban_vhost_5" in generated.haproxy_cfg + assert ( + "stick-table type ipv6 size 100k expire 300s store gpc0" + in generated.haproxy_cfg + ) + assert ( + "http-request track-sc1 src table st_ban_vhost_5 " + "if host_vhost_5 !is_health !is_acme" in generated.haproxy_cfg + ) + assert ( + "http-request deny deny_status 429 if host_vhost_5 !is_health !is_acme " + "{ sc_get_gpc0(1) gt 3 }" in generated.haproxy_cfg + ) + assert ( + "http-request sc-inc-gpc0(1) if host_vhost_5 !is_health !is_acme " + "{ sc_http_req_rate(0,st_ddos_vhost_5) gt 50 }" in generated.haproxy_cfg + ) + assert ( + "http-request sc-inc-gpc0(1) if host_vhost_5 !is_health !is_acme " + "{ sc_conn_cur(0,st_ddos_vhost_5) gt 10 }" in generated.haproxy_cfg + ) + + +def test_generate_one_vhost_with_auto_ban_disabled_emits_nothing() -> None: + vhost = VHost( + id=5, + domain="api.example.com", + backend_url="http://api-backend:9000", + is_active=True, + ssl_enabled=False, + policy_id=10, + ) + policy = Policy( + id=10, + name="Strict", + paranoia_level=2, + inbound_anomaly_threshold=5, + outbound_anomaly_threshold=4, + enforcement_mode=PolicyEnforcementMode.block, + is_active=True, + ddos_protection_enabled=True, + rate_limit_requests=50, + rate_limit_window_seconds=5, + max_connections_per_ip=10, + auto_ban_enabled=False, + ) + + generated = generate(vhosts=[vhost], policies=[policy], rule_overrides=[]) + + assert "track-sc1" not in generated.haproxy_cfg + assert "sc-inc-gpc0" not in generated.haproxy_cfg + assert "st_ban_" not in generated.haproxy_cfg + + +def test_generated_haproxy_cfg_with_auto_ban_validates_with_haproxy( + tmp_path: Path, +) -> None: + haproxy = shutil.which("haproxy") + if haproxy is None: + pytest.skip("haproxy binary is not installed") + vhost = VHost( + id=1, + domain="app.local", + backend_url="http://backend:8000", + is_active=True, + ssl_enabled=False, + policy_id=10, + ) + policy = Policy( + id=10, + name="Strict", + paranoia_level=2, + inbound_anomaly_threshold=5, + outbound_anomaly_threshold=4, + enforcement_mode=PolicyEnforcementMode.block, + is_active=True, + ddos_protection_enabled=True, + rate_limit_requests=100, + rate_limit_window_seconds=10, + max_connections_per_ip=20, + auto_ban_enabled=True, + ban_threshold=10, + ban_duration_seconds=600, + ) + generated = generate(vhosts=[vhost], policies=[policy], rule_overrides=[]) + config_path = tmp_path / "haproxy.cfg" + repo_coraza_cfg = _repo_root() / "configs/haproxy/coraza.cfg" + config_path.write_text( + generated.haproxy_cfg.replace( + "/usr/local/etc/haproxy/coraza.cfg", + str(repo_coraza_cfg), + ), + encoding="utf-8", + ) + + result = subprocess.run( + [haproxy, "-c", "-f", str(config_path)], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr + + def test_generate_one_vhost_with_policy_and_overrides() -> None: vhost = VHost( id=1, diff --git a/src/backend/tests/unit/test_config_renderer_haproxy.py b/src/backend/tests/unit/test_config_renderer_haproxy.py index 1024a73..c2168fd 100644 --- a/src/backend/tests/unit/test_config_renderer_haproxy.py +++ b/src/backend/tests/unit/test_config_renderer_haproxy.py @@ -271,6 +271,10 @@ def _ddos( rate_limit_window_seconds: int = 10, max_connections_per_ip: int = 20, enabled: bool = True, + auto_ban_enabled: bool = False, + ban_stick_table_name: str = "", + ban_threshold: int = 10, + ban_duration_seconds: int = 600, ) -> HaproxyDdos: return HaproxyDdos( enabled=enabled, @@ -278,6 +282,10 @@ def _ddos( rate_limit_requests=rate_limit_requests, rate_limit_window_seconds=rate_limit_window_seconds, max_connections_per_ip=max_connections_per_ip, + auto_ban_enabled=auto_ban_enabled, + ban_stick_table_name=ban_stick_table_name, + ban_threshold=ban_threshold, + ban_duration_seconds=ban_duration_seconds, ) @@ -355,6 +363,147 @@ def test_haproxy_template_omits_ddos_for_disabled_route_only() -> None: assert "if host_vhost_2 { sc_http_req_rate" not in rendered +def test_haproxy_template_omits_auto_ban_blocks_when_disabled() -> None: + rendered = render_haproxy_cfg( + HaproxyRenderContext( + routes=( + HaproxyRoute( + vhost_acl_name="host_api", + vhost_hosts=("api.example.com",), + ssl_provider="none", + backend=_backend("be_api", "api", "api-backend:9000"), + ddos=_ddos(auto_ban_enabled=False), + ), + ), + ) + ) + + assert "track-sc1" not in rendered + assert "sc-inc-gpc0" not in rendered + assert "st_ban_" not in rendered + + +def test_haproxy_template_renders_auto_ban_blocks_when_enabled() -> None: + rendered = render_haproxy_cfg( + HaproxyRenderContext( + routes=( + HaproxyRoute( + vhost_acl_name="host_api", + vhost_hosts=("api.example.com",), + ssl_provider="none", + backend=_backend("be_api", "api", "api-backend:9000"), + ddos=_ddos( + stick_table_name="st_ddos_vhost_1", + rate_limit_requests=100, + max_connections_per_ip=20, + auto_ban_enabled=True, + ban_stick_table_name="st_ban_vhost_1", + ban_threshold=5, + ban_duration_seconds=600, + ), + ), + ), + ) + ) + + assert ( + "backend st_ban_vhost_1\n" + " stick-table type ipv6 size 100k expire 600s store gpc0" in rendered + ) + assert ( + "http-request track-sc1 src table st_ban_vhost_1 " + "if host_api !is_health !is_acme" in rendered + ) + assert ( + "http-request deny deny_status 429 if host_api !is_health !is_acme " + "{ sc_get_gpc0(1) gt 5 }" in rendered + ) + assert ( + "http-request sc-inc-gpc0(1) if host_api !is_health !is_acme " + "{ sc_http_req_rate(0,st_ddos_vhost_1) gt 100 }" in rendered + ) + assert ( + "http-request sc-inc-gpc0(1) if host_api !is_health !is_acme " + "{ sc_conn_cur(0,st_ddos_vhost_1) gt 20 }" in rendered + ) + + +def test_haproxy_render_context_rejects_duplicate_ban_table_names() -> None: + with pytest.raises(ValueError, match="ban_stick_table_name"): + HaproxyRenderContext( + routes=( + HaproxyRoute( + vhost_acl_name="host_one", + vhost_hosts=("one.example.com",), + ssl_provider="none", + backend=_backend("be_one", "srv_one", "one-backend:8000"), + ddos=_ddos( + stick_table_name="st_ddos_one", + auto_ban_enabled=True, + ban_stick_table_name="st_ban_dup", + ), + ), + HaproxyRoute( + vhost_acl_name="host_two", + vhost_hosts=("two.example.com",), + ssl_provider="none", + backend=_backend("be_two", "srv_two", "two-backend:8000"), + ddos=_ddos( + stick_table_name="st_ddos_two", + auto_ban_enabled=True, + ban_stick_table_name="st_ban_dup", + ), + ), + ) + ) + + +@pytest.mark.parametrize( + ("field", "kwargs"), + [ + ( + "ban_stick_table_name", + {"ban_stick_table_name": "", "ban_threshold": 1, "ban_duration_seconds": 1}, + ), + ( + "ban_threshold", + { + "ban_stick_table_name": "st_ban", + "ban_threshold": 0, + "ban_duration_seconds": 1, + }, + ), + ( + "ban_duration_seconds", + { + "ban_stick_table_name": "st_ban", + "ban_threshold": 1, + "ban_duration_seconds": 0, + }, + ), + ( + "ban_duration_seconds_too_large", + { + "ban_stick_table_name": "st_ban", + "ban_threshold": 1, + "ban_duration_seconds": 86401, + }, + ), + ], +) +def test_haproxy_ddos_rejects_invalid_ban_values(field: str, kwargs: dict) -> None: + with pytest.raises(ValueError): + HaproxyDdos( + enabled=True, + stick_table_name="st_ddos", + rate_limit_requests=1, + rate_limit_window_seconds=1, + max_connections_per_ip=1, + auto_ban_enabled=True, + **kwargs, + ) + + def test_haproxy_render_context_rejects_duplicate_ddos_table_names() -> None: with pytest.raises(ValueError, match="stick_table_name"): HaproxyRenderContext( @@ -468,6 +617,46 @@ def test_rendered_haproxy_template_with_ddos_validates_with_haproxy( assert result.returncode == 0, result.stderr +@pytest.mark.skipif(shutil.which("haproxy") is None, reason="haproxy is not installed") +def test_rendered_haproxy_template_with_auto_ban_validates_with_haproxy( + tmp_path: Path, +) -> None: + rendered = render_haproxy_cfg( + HaproxyRenderContext( + routes=( + HaproxyRoute( + vhost_acl_name="host_app", + vhost_hosts=("app.local",), + ssl_provider="none", + backend=_backend("be_app", "app", "backend:8000"), + ddos=_ddos( + stick_table_name="st_ddos_vhost_1", + auto_ban_enabled=True, + ban_stick_table_name="st_ban_vhost_1", + ), + ), + ), + ) + ) + + coraza_cfg = REPO_ROOT / "configs/haproxy/coraza.cfg" + rendered = rendered.replace( + "/usr/local/etc/haproxy/coraza.cfg", str(coraza_cfg) + ) + + rendered_path = tmp_path / "haproxy.cfg" + rendered_path.write_text(rendered) + + result = subprocess.run( + ["haproxy", "-c", "-f", str(rendered_path)], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + @pytest.mark.skipif(shutil.which("haproxy") is None, reason="haproxy is not installed") def test_rendered_haproxy_template_validates_with_haproxy(tmp_path: Path) -> None: rendered = render_haproxy_cfg(_m1_reference_context()) diff --git a/src/backend/tests/unit/test_policy_service.py b/src/backend/tests/unit/test_policy_service.py index 9dc4aeb..e75240c 100644 --- a/src/backend/tests/unit/test_policy_service.py +++ b/src/backend/tests/unit/test_policy_service.py @@ -57,6 +57,9 @@ def test_create_policy_persists_values_and_created_by( assert policy.rate_limit_requests == 100 assert policy.rate_limit_window_seconds == 10 assert policy.max_connections_per_ip == 20 + assert policy.auto_ban_enabled is False + assert policy.ban_threshold == 10 + assert policy.ban_duration_seconds == 600 def test_create_policy_persists_ddos_protection_settings( @@ -116,6 +119,60 @@ def test_update_policy_changes_ddos_protection_settings( assert updated.max_connections_per_ip == 40 +def test_create_policy_persists_auto_ban_settings( + db: Session, + admin_user: User, +) -> None: + service = PolicyService(db) + + policy = service.create_policy( + name="Auto-Ban Hardened", + description=None, + paranoia_level=1, + inbound_anomaly_threshold=5, + outbound_anomaly_threshold=4, + enforcement_mode=PolicyEnforcementMode.block, + created_by=admin_user.id, + ddos_protection_enabled=True, + auto_ban_enabled=True, + ban_threshold=3, + ban_duration_seconds=300, + ) + + assert policy.auto_ban_enabled is True + assert policy.ban_threshold == 3 + assert policy.ban_duration_seconds == 300 + + +def test_update_policy_changes_auto_ban_settings( + db: Session, + admin_user: User, +) -> None: + service = PolicyService(db) + created = service.create_policy( + name="Base Auto-Ban", + description=None, + paranoia_level=1, + inbound_anomaly_threshold=5, + outbound_anomaly_threshold=4, + enforcement_mode=PolicyEnforcementMode.block, + created_by=admin_user.id, + ) + + updated = service.update_policy( + created.id, + { + "auto_ban_enabled": True, + "ban_threshold": 7, + "ban_duration_seconds": 1200, + }, + ) + + assert updated.auto_ban_enabled is True + assert updated.ban_threshold == 7 + assert updated.ban_duration_seconds == 1200 + + def test_create_policy_duplicate_name_raises_error( db: Session, admin_user: User, diff --git a/src/frontend/src/features/policies/PolicyFormModal.test.tsx b/src/frontend/src/features/policies/PolicyFormModal.test.tsx index f05b86d..f2932bf 100644 --- a/src/frontend/src/features/policies/PolicyFormModal.test.tsx +++ b/src/frontend/src/features/policies/PolicyFormModal.test.tsx @@ -40,6 +40,9 @@ const editPolicy: Policy = { rate_limit_requests: 100, rate_limit_window_seconds: 10, max_connections_per_ip: 20, + auto_ban_enabled: false, + ban_threshold: 10, + ban_duration_seconds: 600, created_by: 1, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", @@ -129,3 +132,81 @@ describe("PolicyFormModal DDoS protection fields", () => { ); }); }); + +describe("PolicyFormModal automatic IP banning fields", () => { + it("disables the auto-ban toggle and numeric fields until DDoS protection is enabled", () => { + renderCreateModal(); + + expect( + screen.getByRole("checkbox", { name: /automatic ip banning/i }), + ).toBeDisabled(); + expect(screen.getByLabelText("Ban threshold (violations)")).toBeDisabled(); + expect(screen.getByLabelText("Ban duration (seconds)")).toBeDisabled(); + }); + + it("keeps the ban numeric fields disabled until both DDoS and auto-ban are enabled", async () => { + renderCreateModal(); + + await userEvent.click(screen.getByText("DDoS protection")); + + expect( + screen.getByRole("checkbox", { name: /automatic ip banning/i }), + ).toBeEnabled(); + expect(screen.getByLabelText("Ban threshold (violations)")).toBeDisabled(); + expect(screen.getByLabelText("Ban duration (seconds)")).toBeDisabled(); + + await userEvent.click(screen.getByText("Automatic IP banning")); + + expect(screen.getByLabelText("Ban threshold (violations)")).toBeEnabled(); + expect(screen.getByLabelText("Ban duration (seconds)")).toBeEnabled(); + }); + + it("submits auto-ban fields on create", async () => { + vi.mocked(api.createPolicy).mockResolvedValue({ ...editPolicy, id: 2 }); + + const onSuccess = vi.fn(); + renderCreateModal(onSuccess); + + await userEvent.type(screen.getByLabelText("Name"), "Hardened"); + await userEvent.click(screen.getByText("DDoS protection")); + await userEvent.click(screen.getByText("Automatic IP banning")); + + const banThresholdInput = screen.getByLabelText("Ban threshold (violations)"); + await userEvent.clear(banThresholdInput); + await userEvent.type(banThresholdInput, "3"); + + await userEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + expect(api.createPolicy).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ + auto_ban_enabled: true, + ban_threshold: 3, + ban_duration_seconds: 600, + }), + ); + }); + + it("submits auto-ban fields on update", async () => { + vi.mocked(api.updatePolicy).mockResolvedValue(editPolicy); + + const onSuccess = vi.fn(); + renderEditModal(onSuccess); + + await userEvent.click(screen.getByText("DDoS protection")); + await userEvent.click(screen.getByText("Automatic IP banning")); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + expect(api.updatePolicy).toHaveBeenCalledWith( + "test-token", + editPolicy.id, + expect.objectContaining({ + auto_ban_enabled: true, + ban_threshold: 10, + ban_duration_seconds: 600, + }), + ); + }); +}); diff --git a/src/frontend/src/features/policies/PolicyFormModal.tsx b/src/frontend/src/features/policies/PolicyFormModal.tsx index 84dccb8..44a423d 100644 --- a/src/frontend/src/features/policies/PolicyFormModal.tsx +++ b/src/frontend/src/features/policies/PolicyFormModal.tsx @@ -54,6 +54,15 @@ export function PolicyFormModal(props: PolicyFormModalProps) { const [maxConnectionsPerIp, setMaxConnectionsPerIp] = useState( String(initial?.max_connections_per_ip ?? 20), ); + const [autoBanEnabled, setAutoBanEnabled] = useState( + initial?.auto_ban_enabled ?? false, + ); + const [banThreshold, setBanThreshold] = useState( + String(initial?.ban_threshold ?? 10), + ); + const [banDurationSeconds, setBanDurationSeconds] = useState( + String(initial?.ban_duration_seconds ?? 600), + ); const [submitting, setSubmitting] = useState(false); const [serverError, setServerError] = useState(null); @@ -78,6 +87,9 @@ export function PolicyFormModal(props: PolicyFormModalProps) { rate_limit_requests: Number(rateLimitRequests), rate_limit_window_seconds: Number(rateLimitWindowSeconds), max_connections_per_ip: Number(maxConnectionsPerIp), + auto_ban_enabled: autoBanEnabled, + ban_threshold: Number(banThreshold), + ban_duration_seconds: Number(banDurationSeconds), }); } else { await createPolicy(accessToken, { @@ -91,6 +103,9 @@ export function PolicyFormModal(props: PolicyFormModalProps) { rate_limit_requests: Number(rateLimitRequests), rate_limit_window_seconds: Number(rateLimitWindowSeconds), max_connections_per_ip: Number(maxConnectionsPerIp), + auto_ban_enabled: autoBanEnabled, + ban_threshold: Number(banThreshold), + ban_duration_seconds: Number(banDurationSeconds), }); } onSuccess(); @@ -259,6 +274,50 @@ export function PolicyFormModal(props: PolicyFormModalProps) { onChange={(e) => setMaxConnectionsPerIp(e.target.value)} /> + +
+ + +
+ + setBanThreshold(e.target.value)} + /> +
+ +
+ + setBanDurationSeconds(e.target.value)} + /> +
+
{props.mode === "edit" && ( diff --git a/src/frontend/src/features/policies/types.ts b/src/frontend/src/features/policies/types.ts index 129e9d1..555e75f 100644 --- a/src/frontend/src/features/policies/types.ts +++ b/src/frontend/src/features/policies/types.ts @@ -11,6 +11,9 @@ export type Policy = { rate_limit_requests: number; rate_limit_window_seconds: number; max_connections_per_ip: number; + auto_ban_enabled: boolean; + ban_threshold: number; + ban_duration_seconds: number; created_by: number | null; created_at: string; updated_at: string; @@ -153,6 +156,9 @@ export type PolicyCreate = { rate_limit_requests?: number; rate_limit_window_seconds?: number; max_connections_per_ip?: number; + auto_ban_enabled?: boolean; + ban_threshold?: number; + ban_duration_seconds?: number; }; export type PolicyUpdate = { @@ -167,4 +173,7 @@ export type PolicyUpdate = { rate_limit_requests?: number; rate_limit_window_seconds?: number; max_connections_per_ip?: number; + auto_ban_enabled?: boolean; + ban_threshold?: number; + ban_duration_seconds?: number; }; diff --git a/src/frontend/src/features/vhosts/types.ts b/src/frontend/src/features/vhosts/types.ts index f975113..b094eee 100644 --- a/src/frontend/src/features/vhosts/types.ts +++ b/src/frontend/src/features/vhosts/types.ts @@ -58,6 +58,9 @@ export type VHostAssignedPolicy = { rate_limit_requests: number; rate_limit_window_seconds: number; max_connections_per_ip: number; + auto_ban_enabled: boolean; + ban_threshold: number; + ban_duration_seconds: number; created_by: number | null; created_at: string; updated_at: string; diff --git a/src/frontend/src/pages/policies/PoliciesPage.test.tsx b/src/frontend/src/pages/policies/PoliciesPage.test.tsx index a3fafce..8ffa465 100644 --- a/src/frontend/src/pages/policies/PoliciesPage.test.tsx +++ b/src/frontend/src/pages/policies/PoliciesPage.test.tsx @@ -43,6 +43,9 @@ const mockPolicies = [ rate_limit_requests: 100, rate_limit_window_seconds: 10, max_connections_per_ip: 20, + auto_ban_enabled: false, + ban_threshold: 10, + ban_duration_seconds: 600, created_by: 1, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", diff --git a/src/frontend/src/pages/policies/PolicyDetailPage.test.tsx b/src/frontend/src/pages/policies/PolicyDetailPage.test.tsx index 6c3aa8d..94f4553 100644 --- a/src/frontend/src/pages/policies/PolicyDetailPage.test.tsx +++ b/src/frontend/src/pages/policies/PolicyDetailPage.test.tsx @@ -83,6 +83,9 @@ const mockPolicy: PolicyDetail = { rate_limit_requests: 100, rate_limit_window_seconds: 10, max_connections_per_ip: 20, + auto_ban_enabled: false, + ban_threshold: 10, + ban_duration_seconds: 600, created_by: 1, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T00:00:00Z", diff --git a/src/frontend/src/pages/vhosts/VHostDetailPage.test.tsx b/src/frontend/src/pages/vhosts/VHostDetailPage.test.tsx index 733d745..b4de8c6 100644 --- a/src/frontend/src/pages/vhosts/VHostDetailPage.test.tsx +++ b/src/frontend/src/pages/vhosts/VHostDetailPage.test.tsx @@ -77,6 +77,9 @@ const mockVHost: VHostDetail = { rate_limit_requests: 100, rate_limit_window_seconds: 10, max_connections_per_ip: 20, + auto_ban_enabled: false, + ban_threshold: 10, + ban_duration_seconds: 600, created_by: 1, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T00:00:00Z", From 2335c78e907f810b8fa3bcfa5b667608bec193f6 Mon Sep 17 00:00:00 2001 From: monte Date: Tue, 21 Jul 2026 22:46:41 +0200 Subject: [PATCH 2/2] fix(backend): run ban tracking before the rate/conn deny rules http-request deny stops rule evaluation, so the rate/conn deny rules running before sc-inc-gpc0 meant the abuse counter never incremented and auto-ban could never trigger. Move track-sc1, the already-banned check, and both sc-inc-gpc0 rules ahead of the rate/conn deny rules in both fe_http and fe_https. --- src/backend/app/templates/haproxy.cfg.j2 | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/backend/app/templates/haproxy.cfg.j2 b/src/backend/app/templates/haproxy.cfg.j2 index 062df73..291b9cb 100644 --- a/src/backend/app/templates/haproxy.cfg.j2 +++ b/src/backend/app/templates/haproxy.cfg.j2 @@ -63,18 +63,19 @@ frontend fe_http {% for route in routes %} {% if route.ddos and route.ddos.enabled %} http-request track-sc0 src table {{ route.ddos.stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme - http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { 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 }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } {% if route.ddos.auto_ban_enabled %} # Automatic IP banning: repeated rate/conn violations trip a ban that # lifts after ban_duration_seconds of inactivity (track-sc1 refreshes # the entry's expiry on every request, so a persistent attacker stays - # banned). sc-inc-gpc0 must run before the matching deny rule. + # banned). Ban tracking/increment must run before the rate/conn deny + # rules below, since `deny` stops rule evaluation for the request. http-request track-sc1 src table {{ route.ddos.ban_stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_get_gpc0(1) gt {{ route.ddos.ban_threshold }} } http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } {% endif %} + http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { 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 }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } {% endif %} {% endfor %} {% endif %} @@ -133,14 +134,14 @@ frontend fe_https {% for route in routes %} {% if route.ddos and route.ddos.enabled %} http-request track-sc0 src table {{ route.ddos.stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme - http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { 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 }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } {% if route.ddos.auto_ban_enabled %} http-request track-sc1 src table {{ route.ddos.ban_stick_table_name }} if {{ route.vhost_acl_name }} !is_health !is_acme http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { sc_get_gpc0(1) gt {{ route.ddos.ban_threshold }} } http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_http_req_rate(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.rate_limit_requests }} } http-request sc-inc-gpc0(1) if {{ route.vhost_acl_name }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } {% endif %} + http-request deny deny_status 429 if {{ route.vhost_acl_name }} !is_health !is_acme { 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 }} !is_health !is_acme { sc_conn_cur(0,{{ route.ddos.stick_table_name }}) gt {{ route.ddos.max_connections_per_ip }} } {% endif %} {% endfor %} {% endif %}