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..4a35ccf 100644 --- a/src/backend/app/services/config_renderer.py +++ b/src/backend/app/services/config_renderer.py @@ -140,6 +140,37 @@ def __post_init__(self) -> None: ) +@dataclass(frozen=True) +class HaproxyDdos: + """Per-vhost DDoS protection settings (rate limiting + connection throttling). + + ``stick_table_name`` must be a valid HAProxy identifier; this class only + validates its format. Deriving it from the vhost's ACL suffix and + enforcing uniqueness across routes is the caller's responsibility (see + :func:`app.services.config_generator._to_haproxy_context` and the + uniqueness check in :class:`HaproxyRenderContext`). + """ + + 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 +184,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 +221,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..c294eb5 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,23 @@ 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. Health checks and + # ACME HTTP-01 challenges are exempt so they can't be starved by traffic + # spikes on the same vhost. http_req_rate() counts requests + # observed during the trailing , not a per-second rate, so + # rate_limit_requests is directly "requests per rate_limit_window_seconds". +{% 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 }} } +{% 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 +117,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 }} !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 }} } +{% 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 +152,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..a962115 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 !is_health !is_acme" in generated.haproxy_cfg + ) + assert ( + "http-request deny deny_status 429 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 deny deny_status 429 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_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..1024a73 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,209 @@ 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 !is_health !is_acme" in rendered + ) + assert ( + "http-request deny deny_status 429 if host_api !is_health !is_acme " + "{ sc_http_req_rate(0,st_ddos_vhost_1) gt 100 }" in rendered + ) + assert ( + "http-request deny deny_status 429 if host_api !is_health !is_acme " + "{ 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" && (