TASK-014: Add log path validation to prevent arbitrary file access

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>
This commit is contained in:
2026-04-26 13:49:04 +02:00
parent 2331567bd7
commit 308cf680a7
5 changed files with 223 additions and 4 deletions

View File

@@ -4,9 +4,12 @@ Request, response, and domain models for the config router and service.
"""
import datetime
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.config import get_settings
DNSMode = Literal["yes", "warn", "no", "raw"]
LogEncoding = Literal["auto", "ascii", "utf-8", "UTF-8", "latin-1"]
@@ -213,6 +216,43 @@ class AddLogPathRequest(BaseModel):
description="If true, monitor from current end of file (tail). If false, read from the beginning.",
)
@field_validator("log_path", mode="after")
@classmethod
def validate_log_path(cls, value: str) -> str:
"""Validate that the log path is within allowed directories.
Resolves the path to its canonical form (resolving symlinks) and checks
that it is relative to one of the allowed log directories from settings.
Args:
value: The log path to validate.
Returns:
The validated log path.
Raises:
ValueError: If the path is outside allowed log directories.
"""
settings = get_settings()
try:
resolved_path = Path(value).resolve()
except (OSError, RuntimeError) as e:
raise ValueError(f"Cannot resolve path {value!r}: {e}") from e
for allowed_dir in settings.allowed_log_dirs:
allowed_path = Path(allowed_dir).resolve()
try:
resolved_path.relative_to(allowed_path)
return value
except ValueError:
continue
allowed_dirs_str = ", ".join(settings.allowed_log_dirs)
raise ValueError(
f"Log path {value!r} is outside allowed directories: {allowed_dirs_str}"
)
class LogPreviewRequest(BaseModel):
"""Payload for ``POST /api/config/preview-log``."""