add security fixes, port hardening, and expanded QA coverage

Security fixes:
- Replace debug=True with env-driven FLASK_DEBUG in app.py
- Add _safe_path helper and path-traversal protection to all 6 file routes
  in file_manager.py
- Add peer_name regex and input validation (public_key, name, endpoint_ip)
  in wireguard_manager.py
- Stop returning private key from GET /api/wireguard/keys; return only
  public_key + has_private_key boolean
- Fix is_local_request() XFF bypass by checking remote_addr only, ignoring
  X-Forwarded-For
- Remove duplicate get_all_configs / get_config_summary methods from
  config_manager.py

DevOps:
- Bind 6 internal service ports to 127.0.0.1 in docker-compose.yml
  (radicale, webdav, api, webui, rainloop, filegator)
- Move WebDAV credentials to env vars (WEBDAV_USER, WEBDAV_PASS)
- Pin flask, flask-cors, requests, cryptography, docker to secure minimum
  versions in requirements.txt

QA (560 tests, 0 failures):
- tests/test_wireguard_endpoints.py: 18 new endpoint tests
- tests/test_file_endpoints.py: 24 new endpoint tests incl. path traversal
- tests/test_container_manager.py: expanded from 2 to 30 tests
- tests/test_config_backup_restore_http.py: 25 new tests (new file)
- tests/test_config_apply.py: 9 new tests (new file)

Docs:
- Rewrite README.md with accurate architecture, ports, env vars, security notes
- Rewrite QUICKSTART.md with verified commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 13:08:24 -04:00
parent eb817ffdc5
commit a338836bb8
13 changed files with 1861 additions and 681 deletions
+29 -6
View File
@@ -5,6 +5,7 @@ Handles WebDAV file storage services
"""
import os
import re
import json
import subprocess
import logging
@@ -43,6 +44,28 @@ class FileManager(BaseServiceManager):
except (PermissionError, OSError):
pass
def _safe_path(self, username: str, *parts: str) -> str:
"""Resolve a safe path under files_dir/username.
Whitelists username, joins extra parts, resolves to a real path, and
asserts the result is contained within the user's directory. Raises
ValueError on any sign of path traversal or invalid input.
"""
if not isinstance(username, str) or not re.match(r'^[A-Za-z0-9_.-]{1,64}$', username):
raise ValueError(f"Invalid username: {username!r}")
safe_parts = []
for p in parts:
if p is None:
continue
if not isinstance(p, str):
raise ValueError(f"Invalid path component: {p!r}")
safe_parts.append(p)
user_root = os.path.realpath(os.path.join(self.files_dir, username))
candidate = os.path.realpath(os.path.join(self.files_dir, username, *safe_parts))
if candidate != user_root and not candidate.startswith(user_root + os.sep):
raise ValueError(f"Path traversal detected for user {username!r}: {parts!r}")
return candidate
def _generate_webdav_config(self):
"""Generate WebDAV configuration"""
config = """# WebDAV configuration for Personal Internet Cell
@@ -230,7 +253,7 @@ umask = 022
logger.error("Username and folder_path must not be empty")
return False
try:
full_path = os.path.join(self.files_dir, username, folder_path)
full_path = self._safe_path(username, folder_path)
os.makedirs(full_path, exist_ok=True)
logger.info(f"Created folder {folder_path} for {username}")
@@ -246,7 +269,7 @@ umask = 022
logger.error("Username and folder_path must not be empty")
return False
try:
full_path = os.path.join(self.files_dir, username, folder_path)
full_path = self._safe_path(username, folder_path)
if os.path.exists(full_path):
shutil.rmtree(full_path)
@@ -263,7 +286,7 @@ umask = 022
def upload_file(self, username: str, file_path: str, file_data: bytes) -> bool:
"""Upload a file for a user"""
try:
full_path = os.path.join(self.files_dir, username, file_path)
full_path = self._safe_path(username, file_path)
# Ensure directory exists
os.makedirs(os.path.dirname(full_path), exist_ok=True)
@@ -282,7 +305,7 @@ umask = 022
def download_file(self, username: str, file_path: str) -> Optional[bytes]:
"""Download a file for a user"""
try:
full_path = os.path.join(self.files_dir, username, file_path)
full_path = self._safe_path(username, file_path)
if os.path.exists(full_path):
with open(full_path, 'rb') as f:
@@ -298,7 +321,7 @@ umask = 022
def delete_file(self, username: str, file_path: str) -> bool:
"""Delete a file for a user"""
try:
full_path = os.path.join(self.files_dir, username, file_path)
full_path = self._safe_path(username, file_path)
if os.path.exists(full_path):
os.remove(full_path)
@@ -317,7 +340,7 @@ umask = 022
files = []
try:
full_path = os.path.join(self.files_dir, username, folder_path)
full_path = self._safe_path(username, folder_path)
if os.path.exists(full_path):
for item in os.listdir(full_path):