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:
+29
-6
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user