TASK-010: Replace .split() with shlex.split() for fail2ban_start_command

- Add @field_validator for fail2ban_start_command to validate with shlex.split()
  at startup, catching misconfigured commands with mismatched quotes
- Replace .split() with shlex.split() in jail_config.py line 450
- Replace .split() with shlex.split() in config_misc.py line 154
- Update Backend-Development.md with configuration documentation explaining
  quoted path handling and common pitfalls
- Add comprehensive test suite (8 tests) covering valid commands, quoted paths,
  and mismatched quote errors

This fix ensures commands like '/opt/my tools/fail2ban-client' start are
correctly parsed as two tokens instead of three, preventing execution failures
when the path contains spaces.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 13:04:14 +02:00
parent 4ab767e3d4
commit 8698b89f6a
6 changed files with 160 additions and 38 deletions

View File

@@ -522,6 +522,28 @@ environment:
**Important:** If `Secure=true` is set, browsers will reject the session cookie when the backend is served over HTTP. Ensure your nginx/reverse proxy terminates TLS and passes `X-Forwarded-Proto: https` so FastAPI knows the connection is secure. **Important:** If `Secure=true` is set, browsers will reject the session cookie when the backend is served over HTTP. Ensure your nginx/reverse proxy terminates TLS and passes `X-Forwarded-Proto: https` so FastAPI knows the connection is secure.
### fail2ban_start_command Configuration
The `fail2ban_start_command` setting specifies the shell command used to start the fail2ban daemon during recovery operations (e.g., after a rollback).
**Format & Parsing:**
- The command is split into arguments using `shlex.split()`, which respects shell quoting rules.
- Paths with spaces must be quoted. Example: `"/opt/my tools/fail2ban-client" start`.
- The command is **not** executed through a shell — no shell variables or globbing are interpreted.
**Validation:**
- The command is validated at startup using `shlex.split()`. Mismatched quotes will raise a `ValueError` with the problematic command in the error message.
**Environment Variables:**
```bash
BANGUI_FAIL2BAN_START_COMMAND="fail2ban-client start" # Default
BANGUI_FAIL2BAN_START_COMMAND="systemctl start fail2ban" # systemd
BANGUI_FAIL2BAN_START_COMMAND='"/opt/my tools/fail2ban" start' # Quoted path
```
**Common Pitfall:**
Using `.split()` instead of `shlex.split()` would break commands with spaces in paths. Always use quoted strings for paths that contain whitespace.
### Login Rate Limiting ### Login Rate Limiting
The login endpoint (`POST /api/auth/login`) is protected against brute-force attacks using an in-memory rate limiter. The login endpoint (`POST /api/auth/login`) is protected against brute-force attacks using an in-memory rate limiter.

View File

@@ -1,39 +1,3 @@
## TASK-009 — Blocklist URL has no scheme/host validation — SSRF risk
**Severity:** High
### Where found
`backend/app/models/blocklist.py``BlocklistSourceCreate.url: str = Field(..., min_length=1)`. `backend/app/services/blocklist_service.py``preview_source()` and `_download_text_with_retries()`.
### Why this is needed
An authenticated user can supply `file:///etc/passwd`, `http://169.254.169.254/latest/meta-data/` (AWS metadata service), `http://10.0.0.1/admin`, or `http://localhost:8000/api/setup` as a blocklist URL. The backend fetches it and either returns its contents in the preview response or attempts to parse it as an IP list. This is a Server-Side Request Forgery (SSRF) vulnerability.
### Goal
Restrict blocklist URLs to safe, public HTTP/HTTPS endpoints only.
### What to do
1. Change `url: str` to `url: AnyHttpUrl` in `BlocklistSourceCreate` — this rejects `file://`, `ftp://`, and other non-http schemes.
2. Add a `@field_validator("url")` that:
- Parses the hostname.
- Resolves it via `socket.getaddrinfo()` (or uses `ipaddress.ip_address()` if it is a bare IP).
- Rejects RFC 1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), and IPv6 equivalents.
3. Add a validator for `http://` URLs — consider requiring `https://` only, or adding a configuration flag.
### Possible traps and issues
- DNS rebinding: the hostname resolves to a public IP at validation time but to a private IP at fetch time. Mitigate by re-validating the final connection IP in aiohttp (custom `TCPConnector` or response callback).
- `AnyHttpUrl` allows ports — ensure `http://evil.com:22/` is also blocked or at least safe.
- `socket.getaddrinfo()` is blocking — use `asyncio.get_event_loop().getaddrinfo()` or run in executor.
### Docs changes needed
- `Features.md` — document URL validation constraints for blocklist sources.
- `Backend-Development.md` — SSRF prevention pattern.
### Doc references
- [Features.md](Features.md) — blocklist management
- [Backend-Development.md](Backend-Development.md) — input validation
---
## TASK-010 — `fail2ban_start_command` split with `.split()` instead of `shlex.split()` ## TASK-010 — `fail2ban_start_command` split with `.split()` instead of `shlex.split()`
**Severity:** Low **Severity:** Low

View File

@@ -4,6 +4,7 @@ Follows pydantic-settings patterns: all values are prefixed with BANGUI_
and validated at startup via the Settings singleton. and validated at startup via the Settings singleton.
""" """
import shlex
from typing import Literal from typing import Literal
from pydantic import Field, field_validator from pydantic import Field, field_validator
@@ -151,6 +152,32 @@ class Settings(BaseSettings):
), ),
) )
@field_validator("fail2ban_start_command", mode="after")
@classmethod
def _validate_fail2ban_start_command(cls, value: str) -> str:
"""Validate fail2ban_start_command by attempting to parse it with shlex.
Ensures the command can be split into arguments without shell interpretation.
Raises ValueError if the command contains mismatched quotes.
Args:
value: The fail2ban start command string.
Returns:
The validated command string.
Raises:
ValueError: If the command contains mismatched quotes.
"""
try:
shlex.split(value)
except ValueError as e:
raise ValueError(
f"fail2ban_start_command contains mismatched quotes or is otherwise "
f"unparseable: {value!r}{e}"
) from e
return value
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_prefix="BANGUI_", env_prefix="BANGUI_",
env_file=".env", env_file=".env",

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import shlex
from typing import Annotated from typing import Annotated
import structlog import structlog
@@ -149,7 +150,7 @@ async def restart_fail2ban(
``POST /api/config/jails/{name}/rollback`` ``POST /api/config/jails/{name}/rollback``
if a specific jail is suspect. if a specific jail is suspect.
""" """
start_cmd_parts: list[str] = start_cmd.split() start_cmd_parts: list[str] = shlex.split(start_cmd)
restarted = await jail_service.restart_daemon( restarted = await jail_service.restart_daemon(
socket_path, socket_path,

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import shlex
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Path, Query, Request, status from fastapi import APIRouter, Path, Query, Request, status
@@ -445,7 +446,7 @@ async def rollback_jail(
HTTPException: 400 if *name* contains invalid characters. HTTPException: 400 if *name* contains invalid characters.
HTTPException: 500 if writing the .local override file fails. HTTPException: 500 if writing the .local override file fails.
""" """
start_cmd_parts: list[str] = start_cmd.split() start_cmd_parts: list[str] = shlex.split(start_cmd)
result = await jail_config_service.rollback_jail(config_dir, socket_path, name, start_cmd_parts) result = await jail_config_service.rollback_jail(config_dir, socket_path, name, start_cmd_parts)

View File

@@ -0,0 +1,107 @@
"""Unit tests for application configuration and validation."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def test_fail2ban_start_command_validates_simple_command() -> None:
"""Simple fail2ban commands without special characters are accepted."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command="fail2ban-client start",
)
assert settings.fail2ban_start_command == "fail2ban-client start"
def test_fail2ban_start_command_validates_systemctl_command() -> None:
"""systemctl commands are accepted."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command="systemctl start fail2ban",
)
assert settings.fail2ban_start_command == "systemctl start fail2ban"
def test_fail2ban_start_command_accepts_quoted_paths() -> None:
"""Commands with quoted paths containing spaces are accepted."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command='"/opt/my tools/fail2ban-client" start',
)
assert settings.fail2ban_start_command == '"/opt/my tools/fail2ban-client" start'
def test_fail2ban_start_command_rejects_mismatched_quotes() -> None:
"""Commands with mismatched quotes raise ValidationError."""
with pytest.raises(ValidationError) as exc_info:
Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command='"/opt/my tools/fail2ban-client start',
)
error_msg = str(exc_info.value)
assert "fail2ban_start_command" in error_msg
assert "mismatched quotes" in error_msg or "No closing quotation" in error_msg
def test_fail2ban_start_command_error_includes_problematic_value() -> None:
"""Validation errors include the problematic command value."""
problematic_command = '"/opt/broken start'
with pytest.raises(ValidationError) as exc_info:
Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command=problematic_command,
)
error_msg = str(exc_info.value)
assert problematic_command in error_msg
def test_fail2ban_start_command_default_value_is_valid() -> None:
"""The default fail2ban_start_command value is valid and parseable."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
)
assert settings.fail2ban_start_command == "fail2ban-client start"
def test_fail2ban_start_command_single_quoted() -> None:
"""Commands with single quotes are accepted."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command="'/usr/bin/fail2ban-client' start",
)
assert settings.fail2ban_start_command == "'/usr/bin/fail2ban-client' start"
def test_fail2ban_start_command_multiple_arguments() -> None:
"""Commands with multiple arguments are accepted."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command="fail2ban-client -c /etc/fail2ban start",
)
assert settings.fail2ban_start_command == "fail2ban-client -c /etc/fail2ban start"