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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
24 changes: 24 additions & 0 deletions src/backend/app/models/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/backend/app/routers/policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions src/backend/app/schemas/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/backend/app/services/config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/backend/app/services/config_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/backend/app/services/policy_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -33,6 +36,9 @@
"rate_limit_requests",
"rate_limit_window_seconds",
"max_connections_per_ip",
"auto_ban_enabled",
"ban_threshold",
"ban_duration_seconds",
}


Expand Down Expand Up @@ -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(
Expand All @@ -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,
)
Expand Down
22 changes: 22 additions & 0 deletions src/backend/app/templates/haproxy.cfg.j2
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ 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
{% 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). 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 %}
Comment on lines 65 to +76

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed — real bug. Reordered so track-sc1, the already-banned check, and both sc-inc-gpc0 rules run before the rate/conn deny rules in both fe_http and fe_https, so the abuse counter increments and the ban table's expiry refreshes on every tracked request as intended. (2335c78)

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 %}
Expand Down Expand Up @@ -123,6 +134,12 @@ 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
{% 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 %}
Comment on lines 136 to +142

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed the same way — track-sc1/sc-inc-gpc0 now run before the rate/conn deny rules in fe_https too. (2335c78)

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 %}
Expand Down Expand Up @@ -160,6 +177,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 %}
Expand Down
88 changes: 88 additions & 0 deletions src/backend/tests/integration/test_policies_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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],
Expand Down
Loading
Loading