From bf99087111bbfee5e39b7810783408dedfd903e3 Mon Sep 17 00:00:00 2001 From: monte Date: Tue, 21 Jul 2026 21:51:01 +0200 Subject: [PATCH 1/2] feat(backend): add per-policy DDoS rate limiting and connection throttling 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 --- configs/haproxy/haproxy.cfg | 2 + ..._add_ddos_protection_fields_to_policies.py | 84 ++++++++ src/backend/app/models/policy.py | 32 +++ src/backend/app/routers/policies.py | 4 + src/backend/app/schemas/policy.py | 12 ++ src/backend/app/services/config_generator.py | 30 ++- src/backend/app/services/config_renderer.py | 38 ++++ src/backend/app/services/policy_service.py | 16 ++ src/backend/app/templates/haproxy.cfg.j2 | 38 ++++ .../tests/integration/test_policies_router.py | 100 +++++++++ .../tests/unit/test_config_generator.py | 119 ++++++++++ .../unit/test_config_renderer_haproxy.py | 203 ++++++++++++++++++ src/backend/tests/unit/test_policy_service.py | 61 ++++++ .../policies/PolicyFormModal.test.tsx | 131 +++++++++++ .../src/features/policies/PolicyFormModal.tsx | 76 +++++++ src/frontend/src/features/policies/types.ts | 12 ++ src/frontend/src/features/vhosts/types.ts | 4 + .../src/pages/policies/PoliciesPage.test.tsx | 4 + .../pages/policies/PolicyDetailPage.test.tsx | 4 + .../src/pages/vhosts/VHostDetailPage.test.tsx | 4 + 20 files changed, 972 insertions(+), 2 deletions(-) create mode 100644 src/backend/alembic/versions/473d83df7b55_add_ddos_protection_fields_to_policies.py create mode 100644 src/frontend/src/features/policies/PolicyFormModal.test.tsx diff --git a/configs/haproxy/haproxy.cfg b/configs/haproxy/haproxy.cfg index 37c34f1..760e592 100644 --- a/configs/haproxy/haproxy.cfg +++ b/configs/haproxy/haproxy.cfg @@ -16,6 +16,8 @@ defaults timeout connect 5s timeout client 30s timeout server 30s + # Slow-loris protection: cap the time allowed to receive a full request. + timeout http-request 5s frontend fe_http bind *:80 diff --git a/src/backend/alembic/versions/473d83df7b55_add_ddos_protection_fields_to_policies.py b/src/backend/alembic/versions/473d83df7b55_add_ddos_protection_fields_to_policies.py new file mode 100644 index 0000000..c0fb91e --- /dev/null +++ b/src/backend/alembic/versions/473d83df7b55_add_ddos_protection_fields_to_policies.py @@ -0,0 +1,84 @@ +"""add ddos protection fields to policies + +Revision ID: 473d83df7b55 +Revises: b7c4d8e9f012 +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 = "473d83df7b55" +down_revision: str | Sequence[str] | None = "b7c4d8e9f012" +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( + "ddos_protection_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + batch_op.add_column( + sa.Column( + "rate_limit_requests", + sa.Integer(), + nullable=False, + server_default="100", + ) + ) + batch_op.add_column( + sa.Column( + "rate_limit_window_seconds", + sa.Integer(), + nullable=False, + server_default="10", + ) + ) + batch_op.add_column( + sa.Column( + "max_connections_per_ip", + sa.Integer(), + nullable=False, + server_default="20", + ) + ) + batch_op.create_check_constraint( + "ck_policies_rate_limit_requests", + "rate_limit_requests >= 1", + ) + batch_op.create_check_constraint( + "ck_policies_rate_limit_window_seconds", + "rate_limit_window_seconds BETWEEN 1 AND 3600", + ) + batch_op.create_check_constraint( + "ck_policies_max_connections_per_ip", + "max_connections_per_ip >= 1", + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table("policies") as batch_op: + batch_op.drop_constraint( + "ck_policies_max_connections_per_ip", type_="check" + ) + batch_op.drop_constraint( + "ck_policies_rate_limit_window_seconds", type_="check" + ) + batch_op.drop_constraint("ck_policies_rate_limit_requests", type_="check") + batch_op.drop_column("max_connections_per_ip") + batch_op.drop_column("rate_limit_window_seconds") + batch_op.drop_column("rate_limit_requests") + batch_op.drop_column("ddos_protection_enabled") diff --git a/src/backend/app/models/policy.py b/src/backend/app/models/policy.py index cbad2db..a9f7bee 100644 --- a/src/backend/app/models/policy.py +++ b/src/backend/app/models/policy.py @@ -60,6 +60,18 @@ class Policy(Base): "outbound_anomaly_threshold >= 1", name="ck_policies_outbound_anomaly_threshold", ), + CheckConstraint( + "rate_limit_requests >= 1", + name="ck_policies_rate_limit_requests", + ), + CheckConstraint( + "rate_limit_window_seconds BETWEEN 1 AND 3600", + name="ck_policies_rate_limit_window_seconds", + ), + CheckConstraint( + "max_connections_per_ip >= 1", + name="ck_policies_max_connections_per_ip", + ), ) id: Mapped[int] = mapped_column(primary_key=True) @@ -97,6 +109,26 @@ class Policy(Base): ) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # DDoS protection (HAProxy stick-table rate limiting / connection throttling) + ddos_protection_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + rate_limit_requests: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=100, # max requests per window, per source IP + ) + rate_limit_window_seconds: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=10, # rate-limit window length in seconds + ) + max_connections_per_ip: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=20, # concurrent connection limit per source IP + ) + # 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 1169c49..f17a94b 100644 --- a/src/backend/app/routers/policies.py +++ b/src/backend/app/routers/policies.py @@ -44,6 +44,10 @@ def create_policy( inbound_anomaly_threshold=body.inbound_anomaly_threshold, outbound_anomaly_threshold=body.outbound_anomaly_threshold, enforcement_mode=body.enforcement_mode, + ddos_protection_enabled=body.ddos_protection_enabled, + 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, 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 c9236b0..c7d060a 100644 --- a/src/backend/app/schemas/policy.py +++ b/src/backend/app/schemas/policy.py @@ -19,6 +19,10 @@ class PolicyCreate(BaseModel): inbound_anomaly_threshold: int = 5 outbound_anomaly_threshold: int = 4 enforcement_mode: PolicyEnforcementMode = PolicyEnforcementMode.block + ddos_protection_enabled: bool = False + 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) @field_validator("inbound_anomaly_threshold", "outbound_anomaly_threshold") @classmethod @@ -42,6 +46,10 @@ class PolicyUpdate(BaseModel): outbound_anomaly_threshold: int | None = None enforcement_mode: PolicyEnforcementMode | None = None is_active: bool | None = None + ddos_protection_enabled: bool | None = None + 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) @field_validator("inbound_anomaly_threshold", "outbound_anomaly_threshold") @classmethod @@ -64,6 +72,10 @@ class PolicyResponse(BaseModel): outbound_anomaly_threshold: int enforcement_mode: PolicyEnforcementMode is_active: bool + ddos_protection_enabled: bool + rate_limit_requests: int + rate_limit_window_seconds: int + max_connections_per_ip: 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 0375ed2..69409b5 100644 --- a/src/backend/app/services/config_generator.py +++ b/src/backend/app/services/config_generator.py @@ -15,6 +15,7 @@ CrsPolicyRenderContext, CustomRuleRenderContext, HaproxyBackend, + HaproxyDdos, HaproxyRenderContext, HaproxyRoute, HaproxyServer, @@ -52,7 +53,17 @@ def generate( (vhost for vhost in vhosts if vhost.is_active), key=lambda vhost: vhost.domain, ) - vhost_contexts = [_to_haproxy_context(vhost) for vhost in active_vhosts] + policies_by_id = {policy.id: policy for policy in policies} + + def _policy_for_vhost(vhost: VHost) -> Policy | None: + if vhost.policy_id is None: + return None + return policies_by_id.get(vhost.policy_id) + + vhost_contexts = [ + _to_haproxy_context(vhost, _policy_for_vhost(vhost)) + for vhost in active_vhosts + ] active_policy, active_overrides, active_exclusions, active_custom_rules = ( _pick_active_policy( @@ -193,7 +204,9 @@ def _add_effective_policy_id( effective_policy_ids.add(policy.id) -def _to_haproxy_context(vhost: VHost) -> HaproxyRenderContext: +def _to_haproxy_context( + vhost: VHost, policy: Policy | None +) -> HaproxyRenderContext: if vhost.id is None: raise ValueError(f"Active vhost {vhost.domain!r} has no persisted id") # Use the database id as the naming suffix so that domain names that only @@ -218,12 +231,25 @@ def _to_haproxy_context(vhost: VHost) -> HaproxyRenderContext: ), "/", ) + ddos = ( + HaproxyDdos( + enabled=True, + stick_table_name=f"st_ddos_{suffix}", + 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, + ) + if policy is not None and policy.ddos_protection_enabled + else None + ) + return HaproxyRenderContext( routes=( HaproxyRoute( vhost_acl_name=f"host_{suffix}", vhost_hosts=(vhost.domain,), ssl_provider=vhost.ssl_provider if vhost.ssl_enabled else "none", + ddos=ddos, backend=HaproxyBackend( name=f"be_{suffix}", health_check_path=health_check_path, diff --git a/src/backend/app/services/config_renderer.py b/src/backend/app/services/config_renderer.py index 999d642..ee380c0 100644 --- a/src/backend/app/services/config_renderer.py +++ b/src/backend/app/services/config_renderer.py @@ -140,6 +140,35 @@ def __post_init__(self) -> None: ) +@dataclass(frozen=True) +class HaproxyDdos: + """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. + """ + + enabled: bool + stick_table_name: str + rate_limit_requests: int + rate_limit_window_seconds: int + max_connections_per_ip: int + + def __post_init__(self) -> None: + _validate_haproxy_identifier( + self.stick_table_name, "HaproxyDdos.stick_table_name" + ) + if self.rate_limit_requests < 1: + raise ValueError("HaproxyDdos.rate_limit_requests must be positive") + if not 1 <= self.rate_limit_window_seconds <= 3600: + raise ValueError( + "HaproxyDdos.rate_limit_window_seconds must be between 1 and 3600" + ) + if self.max_connections_per_ip < 1: + raise ValueError("HaproxyDdos.max_connections_per_ip must be positive") + + @dataclass(frozen=True) class HaproxyRoute: """Prepared HAProxy render input with no database behavior. @@ -153,6 +182,7 @@ class HaproxyRoute: vhost_hosts: tuple[str, ...] ssl_provider: str backend: HaproxyBackend + ddos: HaproxyDdos | None = None def __post_init__(self) -> None: _validate_haproxy_identifier( @@ -189,6 +219,14 @@ def __post_init__(self) -> None: ), "HaproxyRenderContext.routes.backend.server_name", ) + _ensure_unique( + ( + route.ddos.stick_table_name + for route in self.routes + if route.ddos is not None + ), + "HaproxyRenderContext.routes.ddos.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 9a5d2db..c89108b 100644 --- a/src/backend/app/services/policy_service.py +++ b/src/backend/app/services/policy_service.py @@ -14,6 +14,10 @@ "outbound_anomaly_threshold", "enforcement_mode", "is_active", + "ddos_protection_enabled", + "rate_limit_requests", + "rate_limit_window_seconds", + "max_connections_per_ip", } # description is intentionally included: it is nullable and may be set to None. @@ -25,6 +29,10 @@ "outbound_anomaly_threshold", "enforcement_mode", "is_active", + "ddos_protection_enabled", + "rate_limit_requests", + "rate_limit_window_seconds", + "max_connections_per_ip", } @@ -85,6 +93,10 @@ def create_policy( outbound_anomaly_threshold: int, enforcement_mode: PolicyEnforcementMode, created_by: int | None, + ddos_protection_enabled: bool = False, + rate_limit_requests: int = 100, + rate_limit_window_seconds: int = 10, + max_connections_per_ip: int = 20, ) -> Policy: """Create and persist a new policy.""" policy = Policy( @@ -94,6 +106,10 @@ def create_policy( inbound_anomaly_threshold=inbound_anomaly_threshold, outbound_anomaly_threshold=outbound_anomaly_threshold, enforcement_mode=enforcement_mode, + ddos_protection_enabled=ddos_protection_enabled, + rate_limit_requests=rate_limit_requests, + rate_limit_window_seconds=rate_limit_window_seconds, + max_connections_per_ip=max_connections_per_ip, 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 5ab8efa..ed3ef7a 100644 --- a/src/backend/app/templates/haproxy.cfg.j2 +++ b/src/backend/app/templates/haproxy.cfg.j2 @@ -16,6 +16,8 @@ defaults timeout connect 5s timeout client 30s timeout server 30s + # Slow-loris protection: cap the time allowed to receive a full request. + timeout http-request 5s frontend fe_http bind *:80 @@ -50,6 +52,19 @@ frontend fe_http # cannot be bypassed by header spoofing. http-request set-header X-Forwarded-For %[src] +{% set has_ddos_http = routes | selectattr("ddos") | list | length > 0 %} +{% if has_ddos_http %} + # Per-vhost DDoS protection: request-rate limiting and connection + # throttling via a dedicated stick-table per vhost. +{% 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 }} + 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 }} } +{% endif %} +{% endfor %} +{% endif %} + # Fail closed (503) when HAProxy has no usable Coraza SPOA server. http-request set-var(txn.waf_degraded_reason) str(coraza-unavailable) if !is_health { nbsrv(coraza-spoa) eq 0 } http-request set-log-level err if { var(txn.waf_degraded_reason) -m found } @@ -98,6 +113,18 @@ frontend fe_https http-request set-header X-Forwarded-For %[src] http-request set-header X-Forwarded-Proto https +{% set has_ddos_https = routes | selectattr("ddos") | list | length > 0 %} +{% if has_ddos_https %} + # Per-vhost DDoS protection (see 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 }} + 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 }} } +{% endif %} +{% endfor %} +{% endif %} + # WAF inspection and fail-closed handling (same as fe_http). http-request set-var(txn.waf_degraded_reason) str(coraza-unavailable) if !is_health { nbsrv(coraza-spoa) eq 0 } http-request set-log-level err if { var(txn.waf_degraded_reason) -m found } @@ -121,6 +148,17 @@ frontend fe_https {% endif %} +{% set has_ddos_backends = routes | selectattr("ddos") | list | length > 0 %} +{% if has_ddos_backends %} +# --- DDoS protection stick-tables --------------------------------- +{% for route in routes %} +{% if route.ddos and route.ddos.enabled %} +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 + +{% endif %} +{% endfor %} +{% endif %} # --- Application backends ---------------------------------------- {% for route in routes %} backend {{ route.backend.name }} diff --git a/src/backend/tests/integration/test_policies_router.py b/src/backend/tests/integration/test_policies_router.py index e259f26..0a03834 100644 --- a/src/backend/tests/integration/test_policies_router.py +++ b/src/backend/tests/integration/test_policies_router.py @@ -125,6 +125,81 @@ def test_create_policy_invalid_paranoia_level_returns_422( assert resp.status_code == 422 +def test_create_policy_default_ddos_settings( + client: TestClient, admin_token: dict[str, str] +) -> None: + """A policy created without DDoS fields gets safe, protection-off defaults.""" + body = _create_policy(client, admin_token, name="No DDoS overrides") + + assert body["ddos_protection_enabled"] is False + assert body["rate_limit_requests"] == 100 + assert body["rate_limit_window_seconds"] == 10 + assert body["max_connections_per_ip"] == 20 + + +def test_create_policy_with_ddos_settings_returns_201( + client: TestClient, admin_token: dict[str, str] +) -> None: + """DDoS fields round-trip through creation.""" + resp = client.post( + "/policies", + headers=admin_token, + json={ + "name": "DDoS Hardened", + "paranoia_level": 2, + "inbound_anomaly_threshold": 5, + "outbound_anomaly_threshold": 5, + "ddos_protection_enabled": True, + "rate_limit_requests": 50, + "rate_limit_window_seconds": 5, + "max_connections_per_ip": 10, + }, + ) + + assert resp.status_code == 201 + body = resp.json() + assert body["ddos_protection_enabled"] is True + assert body["rate_limit_requests"] == 50 + assert body["rate_limit_window_seconds"] == 5 + assert body["max_connections_per_ip"] == 10 + + +def test_create_policy_invalid_rate_limit_requests_returns_422( + client: TestClient, admin_token: dict[str, str] +) -> None: + """Out-of-range rate_limit_requests should be rejected at router validation.""" + resp = client.post( + "/policies", + headers=admin_token, + json={ + "name": "Invalid rate limit", + "paranoia_level": 2, + "inbound_anomaly_threshold": 5, + "outbound_anomaly_threshold": 5, + "rate_limit_requests": 0, + }, + ) + assert resp.status_code == 422 + + +def test_create_policy_invalid_rate_limit_window_returns_422( + client: TestClient, admin_token: dict[str, str] +) -> None: + """Out-of-range rate_limit_window_seconds is rejected at router validation.""" + resp = client.post( + "/policies", + headers=admin_token, + json={ + "name": "Invalid window", + "paranoia_level": 2, + "inbound_anomaly_threshold": 5, + "outbound_anomaly_threshold": 5, + "rate_limit_window_seconds": 3601, + }, + ) + assert resp.status_code == 422 + + # --------------------------------------------------------------------------- # GET /policies # --------------------------------------------------------------------------- @@ -262,6 +337,31 @@ def test_patch_policy_admin_partial_update( assert body["is_active"] is False +def test_patch_policy_updates_ddos_settings( + client: TestClient, + admin_token: dict[str, str], +) -> None: + """PATCH updates DDoS protection fields.""" + created = _create_policy(client, admin_token, name="Patch DDoS") + + resp = client.patch( + f"/policies/{created['id']}", + headers=admin_token, + json={ + "ddos_protection_enabled": True, + "rate_limit_requests": 200, + "rate_limit_window_seconds": 30, + "max_connections_per_ip": 40, + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["ddos_protection_enabled"] is True + assert body["rate_limit_requests"] == 200 + assert body["rate_limit_window_seconds"] == 30 + assert body["max_connections_per_ip"] == 40 + + 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 0cba99d..34ae240 100644 --- a/src/backend/tests/unit/test_config_generator.py +++ b/src/backend/tests/unit/test_config_generator.py @@ -244,6 +244,125 @@ def test_generate_one_vhost_with_policy() -> None: assert "setvar:tx.blocking_paranoia_level=2" in generated.crs_setup_conf +def test_generate_one_vhost_with_ddos_protection_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, + ) + + generated = generate(vhosts=[vhost], policies=[policy], rule_overrides=[]) + + assert "backend st_ddos_vhost_5" in generated.haproxy_cfg + assert ( + "stick-table type ipv6 size 100k expire 5s store " + "http_req_rate(5s),conn_cur" in generated.haproxy_cfg + ) + assert ( + "http-request track-sc0 src table st_ddos_vhost_5 if host_vhost_5" + in generated.haproxy_cfg + ) + assert ( + "http-request deny deny_status 429 if host_vhost_5 " + "{ sc_http_req_rate(0,st_ddos_vhost_5) gt 50 }" in generated.haproxy_cfg + ) + assert ( + "http-request deny deny_status 429 if host_vhost_5 " + "{ sc_conn_cur(0,st_ddos_vhost_5) gt 10 }" in generated.haproxy_cfg + ) + + +def test_generate_one_vhost_with_ddos_protection_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=False, + ) + + generated = generate(vhosts=[vhost], policies=[policy], rule_overrides=[]) + + assert "stick-table" not in generated.haproxy_cfg + assert "track-sc0" not in generated.haproxy_cfg + assert "deny_status 429" not in generated.haproxy_cfg + + +def test_generated_haproxy_cfg_with_ddos_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, + ) + 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 63c31ec..e0f2888 100644 --- a/src/backend/tests/unit/test_config_renderer_haproxy.py +++ b/src/backend/tests/unit/test_config_renderer_haproxy.py @@ -6,6 +6,7 @@ from app.services.config_renderer import ( HaproxyBackend, + HaproxyDdos, HaproxyRenderContext, HaproxyRoute, HaproxyServer, @@ -264,6 +265,208 @@ def test_haproxy_backend_rejects_empty_values(kwargs: dict) -> None: ) +def _ddos( + stick_table_name: str = "st_ddos_vhost_1", + rate_limit_requests: int = 100, + rate_limit_window_seconds: int = 10, + max_connections_per_ip: int = 20, + enabled: bool = True, +) -> HaproxyDdos: + return HaproxyDdos( + enabled=enabled, + stick_table_name=stick_table_name, + rate_limit_requests=rate_limit_requests, + rate_limit_window_seconds=rate_limit_window_seconds, + max_connections_per_ip=max_connections_per_ip, + ) + + +def test_haproxy_template_omits_ddos_blocks_when_disabled() -> None: + rendered = render_haproxy_cfg(_m1_reference_context()) + + assert "stick-table" not in rendered + assert "track-sc0" not in rendered + assert "deny_status 429" not in rendered + + +def test_haproxy_template_renders_ddos_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, + rate_limit_window_seconds=10, + max_connections_per_ip=20, + ), + ), + ), + ) + ) + + assert ( + "backend st_ddos_vhost_1\n" + " stick-table type ipv6 size 100k expire 10s " + "store http_req_rate(10s),conn_cur" in rendered + ) + assert ( + "http-request track-sc0 src table st_ddos_vhost_1 if host_api" in rendered + ) + assert ( + "http-request deny deny_status 429 if host_api " + "{ sc_http_req_rate(0,st_ddos_vhost_1) gt 100 }" in rendered + ) + assert ( + "http-request deny deny_status 429 if host_api " + "{ sc_conn_cur(0,st_ddos_vhost_1) gt 20 }" in rendered + ) + assert "timeout http-request 5s" in rendered + + +def test_haproxy_template_omits_ddos_for_disabled_route_only() -> None: + rendered = render_haproxy_cfg( + HaproxyRenderContext( + routes=( + HaproxyRoute( + vhost_acl_name="host_vhost_1", + vhost_hosts=("one.example.com",), + ssl_provider="none", + backend=_backend("be_one", "srv_one", "one-backend:8000"), + ddos=_ddos(stick_table_name="st_ddos_vhost_1"), + ), + HaproxyRoute( + vhost_acl_name="host_vhost_2", + vhost_hosts=("two.example.com",), + ssl_provider="none", + backend=_backend("be_two", "srv_two", "two-backend:8000"), + ), + ) + ) + ) + + assert "table st_ddos_vhost_1 if host_vhost_1" in rendered + assert "table st_ddos_vhost_1 if host_vhost_2" not in rendered + assert "if host_vhost_2 { sc_http_req_rate" not in rendered + + +def test_haproxy_render_context_rejects_duplicate_ddos_table_names() -> None: + with pytest.raises(ValueError, match="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_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_dup"), + ), + ) + ) + + +@pytest.mark.parametrize( + ("field", "kwargs"), + [ + ( + "stick_table_name", + { + "stick_table_name": "", + "rate_limit_requests": 1, + "rate_limit_window_seconds": 1, + "max_connections_per_ip": 1, + }, + ), + ( + "rate_limit_requests", + { + "stick_table_name": "st_ddos", + "rate_limit_requests": 0, + "rate_limit_window_seconds": 1, + "max_connections_per_ip": 1, + }, + ), + ( + "rate_limit_window_seconds", + { + "stick_table_name": "st_ddos", + "rate_limit_requests": 1, + "rate_limit_window_seconds": 0, + "max_connections_per_ip": 1, + }, + ), + ( + "rate_limit_window_seconds_too_large", + { + "stick_table_name": "st_ddos", + "rate_limit_requests": 1, + "rate_limit_window_seconds": 3601, + "max_connections_per_ip": 1, + }, + ), + ( + "max_connections_per_ip", + { + "stick_table_name": "st_ddos", + "rate_limit_requests": 1, + "rate_limit_window_seconds": 1, + "max_connections_per_ip": 0, + }, + ), + ], +) +def test_haproxy_ddos_rejects_invalid_values(field: str, kwargs: dict) -> None: + with pytest.raises(ValueError): + HaproxyDdos(enabled=True, **kwargs) + + +@pytest.mark.skipif(shutil.which("haproxy") is None, reason="haproxy is not installed") +def test_rendered_haproxy_template_with_ddos_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"), + ), + ), + ) + ) + + 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 ee0ed03..9dc4aeb 100644 --- a/src/backend/tests/unit/test_policy_service.py +++ b/src/backend/tests/unit/test_policy_service.py @@ -53,6 +53,67 @@ def test_create_policy_persists_values_and_created_by( assert policy.enforcement_mode == PolicyEnforcementMode.detect_only assert policy.is_active is True assert policy.created_by == admin_user.id + assert policy.ddos_protection_enabled is False + assert policy.rate_limit_requests == 100 + assert policy.rate_limit_window_seconds == 10 + assert policy.max_connections_per_ip == 20 + + +def test_create_policy_persists_ddos_protection_settings( + db: Session, + admin_user: User, +) -> None: + service = PolicyService(db) + + policy = service.create_policy( + name="DDoS 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, + rate_limit_requests=50, + rate_limit_window_seconds=5, + max_connections_per_ip=10, + ) + + assert policy.ddos_protection_enabled is True + assert policy.rate_limit_requests == 50 + assert policy.rate_limit_window_seconds == 5 + assert policy.max_connections_per_ip == 10 + + +def test_update_policy_changes_ddos_protection_settings( + db: Session, + admin_user: User, +) -> None: + service = PolicyService(db) + created = service.create_policy( + name="Base", + 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, + { + "ddos_protection_enabled": True, + "rate_limit_requests": 200, + "rate_limit_window_seconds": 30, + "max_connections_per_ip": 40, + }, + ) + + assert updated.ddos_protection_enabled is True + assert updated.rate_limit_requests == 200 + assert updated.rate_limit_window_seconds == 30 + assert updated.max_connections_per_ip == 40 def test_create_policy_duplicate_name_raises_error( diff --git a/src/frontend/src/features/policies/PolicyFormModal.test.tsx b/src/frontend/src/features/policies/PolicyFormModal.test.tsx new file mode 100644 index 0000000..f05b86d --- /dev/null +++ b/src/frontend/src/features/policies/PolicyFormModal.test.tsx @@ -0,0 +1,131 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { AuthContext } from "@/features/auth/auth-context.shared"; +import type { AuthContextValue } from "@/features/auth/auth-context.types"; + +import * as api from "./api"; +import { PolicyFormModal } from "./PolicyFormModal"; +import type { Policy } from "./types"; + +vi.mock("./api"); + +function makeAuthContext(overrides: Partial = {}): AuthContextValue { + return { + user: null, + role: null, + accessToken: "test-token", + isAuthenticated: true, + isLoading: false, + loginError: null, + hasRole: vi.fn().mockReturnValue(true), + signIn: vi.fn(), + signOut: vi.fn(), + refreshCurrentUser: vi.fn(), + ...overrides, + }; +} + +const editPolicy: Policy = { + id: 1, + name: "Default WAF", + description: null, + paranoia_level: 2, + inbound_anomaly_threshold: 5, + outbound_anomaly_threshold: 4, + enforcement_mode: "block", + is_active: true, + ddos_protection_enabled: false, + rate_limit_requests: 100, + rate_limit_window_seconds: 10, + max_connections_per_ip: 20, + created_by: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +function renderCreateModal(onSuccess = vi.fn(), onClose = vi.fn()) { + return render( + + + , + ); +} + +function renderEditModal(onSuccess = vi.fn(), onClose = vi.fn()) { + return render( + + + , + ); +} + +describe("PolicyFormModal DDoS protection fields", () => { + it("disables the numeric fields until DDoS protection is enabled", () => { + renderCreateModal(); + + expect(screen.getByLabelText("Rate limit (requests)")).toBeDisabled(); + expect(screen.getByLabelText("Rate limit window (seconds)")).toBeDisabled(); + expect(screen.getByLabelText("Max connections per IP")).toBeDisabled(); + }); + + it("enables the numeric fields once the toggle is checked", async () => { + renderCreateModal(); + + await userEvent.click(screen.getByText("DDoS protection")); + + expect(screen.getByLabelText("Rate limit (requests)")).toBeEnabled(); + expect(screen.getByLabelText("Rate limit window (seconds)")).toBeEnabled(); + expect(screen.getByLabelText("Max connections per IP")).toBeEnabled(); + }); + + it("submits DDoS 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")); + + const rateLimitInput = screen.getByLabelText("Rate limit (requests)"); + await userEvent.clear(rateLimitInput); + await userEvent.type(rateLimitInput, "50"); + + await userEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + expect(api.createPolicy).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ + ddos_protection_enabled: true, + rate_limit_requests: 50, + rate_limit_window_seconds: 10, + max_connections_per_ip: 20, + }), + ); + }); + + it("submits DDoS 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.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + expect(api.updatePolicy).toHaveBeenCalledWith( + "test-token", + editPolicy.id, + expect.objectContaining({ + ddos_protection_enabled: true, + rate_limit_requests: 100, + rate_limit_window_seconds: 10, + max_connections_per_ip: 20, + }), + ); + }); +}); diff --git a/src/frontend/src/features/policies/PolicyFormModal.tsx b/src/frontend/src/features/policies/PolicyFormModal.tsx index 3bc6ad5..84dccb8 100644 --- a/src/frontend/src/features/policies/PolicyFormModal.tsx +++ b/src/frontend/src/features/policies/PolicyFormModal.tsx @@ -42,6 +42,18 @@ export function PolicyFormModal(props: PolicyFormModalProps) { ); const outboundThreshold = String(initial?.outbound_anomaly_threshold ?? 4); const [isActive, setIsActive] = useState(initial?.is_active ?? true); + const [ddosProtectionEnabled, setDdosProtectionEnabled] = useState( + initial?.ddos_protection_enabled ?? false, + ); + const [rateLimitRequests, setRateLimitRequests] = useState( + String(initial?.rate_limit_requests ?? 100), + ); + const [rateLimitWindowSeconds, setRateLimitWindowSeconds] = useState( + String(initial?.rate_limit_window_seconds ?? 10), + ); + const [maxConnectionsPerIp, setMaxConnectionsPerIp] = useState( + String(initial?.max_connections_per_ip ?? 20), + ); const [submitting, setSubmitting] = useState(false); const [serverError, setServerError] = useState(null); @@ -62,6 +74,10 @@ export function PolicyFormModal(props: PolicyFormModalProps) { inbound_anomaly_threshold: Number(inboundThreshold), outbound_anomaly_threshold: Number(outboundThreshold), is_active: isActive, + ddos_protection_enabled: ddosProtectionEnabled, + rate_limit_requests: Number(rateLimitRequests), + rate_limit_window_seconds: Number(rateLimitWindowSeconds), + max_connections_per_ip: Number(maxConnectionsPerIp), }); } else { await createPolicy(accessToken, { @@ -71,6 +87,10 @@ export function PolicyFormModal(props: PolicyFormModalProps) { paranoia_level: Number(paranoiaLevel) as 1 | 2 | 3 | 4, inbound_anomaly_threshold: Number(inboundThreshold), outbound_anomaly_threshold: Number(outboundThreshold), + ddos_protection_enabled: ddosProtectionEnabled, + rate_limit_requests: Number(rateLimitRequests), + rate_limit_window_seconds: Number(rateLimitWindowSeconds), + max_connections_per_ip: Number(maxConnectionsPerIp), }); } onSuccess(); @@ -185,6 +205,62 @@ export function PolicyFormModal(props: PolicyFormModalProps) { /> +
+ + +
+ + setRateLimitRequests(e.target.value)} + /> +
+ +
+ + setRateLimitWindowSeconds(e.target.value)} + /> +
+ +
+ + setMaxConnectionsPerIp(e.target.value)} + /> +
+
+ {props.mode === "edit" && (