Restrict monitored log paths to a configurable allowlist of safe directories to prevent authenticated users from instructing fail2ban to monitor arbitrary files on the system, which could leak contents via fail2ban logging. Changes: - Add 'allowed_log_dirs' setting to Settings (defaults to /var/log, /config/log) - Add @field_validator to AddLogPathRequest to validate log paths at request time - Validator resolves paths to canonical form and checks against allowed prefixes - Use Path.is_relative_to() to prevent prefix bypass attacks like /var/log_evil - Add comprehensive tests for valid/invalid paths and symlink handling - Update Features.md and Backend-Development.md with security documentation Security improvements: - Blocks access to sensitive files (/etc/shadow, /etc/passwd, etc.) - Resolves symlinks before validation to prevent escape routes - Uses proper path comparison instead of string prefix matching - Configurable via BANGUI_ALLOWED_LOG_DIRS environment variable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
205 lines
7.2 KiB
Python
205 lines
7.2 KiB
Python
"""Application configuration loaded from environment variables and .env file.
|
|
|
|
Follows pydantic-settings patterns: all values are prefixed with BANGUI_
|
|
and validated at startup via the Settings singleton.
|
|
"""
|
|
|
|
import shlex
|
|
from typing import Literal
|
|
|
|
from pydantic import Field, field_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
from app.utils.constants import (
|
|
DEFAULT_DATABASE_PATH,
|
|
DEFAULT_FAIL2BAN_SOCKET,
|
|
DEFAULT_SESSION_DURATION_MINUTES,
|
|
)
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""BanGUI runtime configuration.
|
|
|
|
All fields are loaded from environment variables prefixed with ``BANGUI_``
|
|
or from a ``.env`` file located next to the process working directory.
|
|
The application will raise a :class:`pydantic.ValidationError` on startup
|
|
if any required field is missing or has an invalid value.
|
|
"""
|
|
|
|
database_path: str = Field(
|
|
default=DEFAULT_DATABASE_PATH,
|
|
description="Filesystem path to the BanGUI SQLite application database.",
|
|
)
|
|
fail2ban_socket: str = Field(
|
|
default=DEFAULT_FAIL2BAN_SOCKET,
|
|
description="Path to the fail2ban Unix domain socket.",
|
|
)
|
|
session_secret: str = Field(
|
|
...,
|
|
description=(
|
|
"Secret key used when generating session tokens. "
|
|
"Must be unique and never committed to source control."
|
|
),
|
|
)
|
|
session_duration_minutes: int = Field(
|
|
default=DEFAULT_SESSION_DURATION_MINUTES,
|
|
ge=1,
|
|
description="Number of minutes a session token remains valid after creation.",
|
|
)
|
|
session_cache_enabled: bool = Field(
|
|
default=False,
|
|
description=(
|
|
"Enable the in-memory session validation cache. "
|
|
"Disable it in multi-worker deployments to avoid stale revoked sessions."
|
|
),
|
|
)
|
|
session_cache_ttl_seconds: float = Field(
|
|
default=10.0,
|
|
ge=0.0,
|
|
description=(
|
|
"How long (seconds) a cached session validation entry remains fresh. "
|
|
"Ignored when session_cache_enabled is false."
|
|
),
|
|
)
|
|
http_request_timeout_seconds: float = Field(
|
|
default=20.0,
|
|
ge=0.0,
|
|
description="Maximum total time in seconds for outbound external HTTP requests.",
|
|
)
|
|
http_connect_timeout_seconds: float = Field(
|
|
default=5.0,
|
|
ge=0.0,
|
|
description="Maximum time in seconds to establish outbound external HTTP connections.",
|
|
)
|
|
http_max_connections: int = Field(
|
|
default=10,
|
|
ge=1,
|
|
description="Maximum number of concurrent outbound HTTP connections.",
|
|
)
|
|
http_keepalive_timeout_seconds: float = Field(
|
|
default=15.0,
|
|
ge=0.0,
|
|
description="How long idle keepalive connections are retained by the HTTP connector.",
|
|
)
|
|
timezone: str = Field(
|
|
default="UTC",
|
|
description="IANA timezone name used when displaying timestamps in the UI.",
|
|
)
|
|
session_cookie_httponly: bool = Field(
|
|
default=True,
|
|
description=(
|
|
"Mark the session cookie as HttpOnly so browser scripts cannot access it."
|
|
),
|
|
)
|
|
session_cookie_samesite: Literal["lax", "strict", "none"] = Field(
|
|
default="lax",
|
|
description=(
|
|
"SameSite policy for the session cookie. "
|
|
"Use 'lax', 'strict', or 'none' depending on deployment requirements."
|
|
),
|
|
)
|
|
session_cookie_secure: bool = Field(
|
|
default=True,
|
|
description=(
|
|
"Set the session cookie Secure flag when the backend is served over HTTPS. "
|
|
"Defaults to True for security. Set to False only for local development over HTTP."
|
|
),
|
|
)
|
|
cors_allowed_origins: str | list[str] = Field(
|
|
default_factory=list,
|
|
description=(
|
|
"Comma-separated list of allowed CORS origins when the frontend is "
|
|
"served from a different origin than the backend. "
|
|
"Leave empty to disable cross-origin requests in production."
|
|
),
|
|
)
|
|
|
|
@field_validator("cors_allowed_origins", mode="before")
|
|
@classmethod
|
|
def _normalize_cors_origins(cls, value: str | list[str] | None) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, str):
|
|
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
|
return value
|
|
|
|
log_level: str = Field(
|
|
default="info",
|
|
description="Application log level: debug | info | warning | error | critical.",
|
|
)
|
|
geoip_db_path: str | None = Field(
|
|
default=None,
|
|
description=(
|
|
"Optional path to a MaxMind GeoLite2-Country .mmdb file. "
|
|
"When set, failed ip-api.com lookups fall back to local resolution."
|
|
),
|
|
)
|
|
fail2ban_config_dir: str = Field(
|
|
default="/config/fail2ban",
|
|
description=(
|
|
"Path to the fail2ban configuration directory. "
|
|
"Must contain subdirectories jail.d/, filter.d/, and action.d/. "
|
|
"Used for listing, viewing, and editing configuration files through the web UI."
|
|
),
|
|
)
|
|
allowed_log_dirs: list[str] = Field(
|
|
default_factory=lambda: ["/var/log", "/config/log"],
|
|
description=(
|
|
"List of allowed directory prefixes for jail log paths. "
|
|
"Any log path added must resolve to a path within one of these directories. "
|
|
"Use absolute paths. Symlinks are resolved before validation."
|
|
),
|
|
)
|
|
fail2ban_start_command: str = Field(
|
|
default="fail2ban-client start",
|
|
description=(
|
|
"Shell command used to start (not reload) the fail2ban daemon during "
|
|
"recovery rollback. Split by whitespace to build the argument list — "
|
|
"no shell interpretation is performed. "
|
|
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
|
|
),
|
|
)
|
|
|
|
@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(
|
|
env_prefix="BANGUI_",
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False,
|
|
)
|
|
|
|
|
|
def get_settings() -> Settings:
|
|
"""Return a fresh :class:`Settings` instance loaded from the environment.
|
|
|
|
Returns:
|
|
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
|
|
if required keys are absent or values fail validation.
|
|
"""
|
|
return Settings() # pydantic-settings populates required fields from env vars
|