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
+30 -13
View File
@@ -343,8 +343,16 @@ def _local_subnets():
def is_local_request():
# SECURITY: do NOT use X-Forwarded-For for auth. Caddy (and any reverse
# proxy) sets XFF to the original client IP, but the TCP peer that reaches
# this Flask process is always the proxy itself (an RFC-1918 Docker IP).
# Trusting XFF would let any internet client claim a local IP via that
# header. Only the direct TCP peer (request.remote_addr) is trustworthy:
# all legitimate local traffic comes directly from the Docker network or
# loopback, so remote_addr being local is a sufficient and necessary
# condition. The XFF header is read for logging only, never for access
# decisions.
remote_addr = request.remote_addr
forwarded_for = request.headers.get('X-Forwarded-For', '')
def _allowed(addr):
if not addr:
@@ -374,14 +382,7 @@ def is_local_request():
pass
return False
if _allowed(remote_addr):
return True
# Only trust the LAST X-Forwarded-For entry — that is what the reverse proxy appended.
if forwarded_for:
last_hop = forwarded_for.split(',')[-1].strip()
if _allowed(last_hop):
return True
return False
return _allowed(remote_addr)
@app.route('/health', methods=['GET'])
def health_check():
@@ -1416,10 +1417,13 @@ def test_network():
# WireGuard API
@app.route('/api/wireguard/keys', methods=['GET'])
def get_wireguard_keys():
"""Get WireGuard keys."""
"""Get WireGuard keys (public key only; private key never leaves the server)."""
try:
result = wireguard_manager.get_keys()
return jsonify(result)
keys = wireguard_manager.get_keys()
return jsonify({
'public_key': keys.get('public_key', ''),
'has_private_key': bool(keys.get('private_key')),
})
except Exception as e:
logger.error(f"Error getting WireGuard keys: {e}")
return jsonify({"error": str(e)}), 500
@@ -2149,6 +2153,8 @@ def create_folder():
return jsonify({"error": "No data provided"}), 400
result = file_manager.create_folder(data)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"Error creating folder: {e}")
return jsonify({"error": str(e)}), 500
@@ -2159,6 +2165,8 @@ def delete_folder(username, folder_path):
try:
result = file_manager.delete_folder(username, folder_path)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"Error deleting folder: {e}")
return jsonify({"error": str(e)}), 500
@@ -2175,6 +2183,8 @@ def upload_file(username):
result = file_manager.upload_file(username, file, path)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"Error uploading file: {e}")
return jsonify({"error": str(e)}), 500
@@ -2185,6 +2195,8 @@ def download_file(username, file_path):
try:
result = file_manager.download_file(username, file_path)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"Error downloading file: {e}")
return jsonify({"error": str(e)}), 500
@@ -2195,6 +2207,8 @@ def delete_file(username, file_path):
try:
result = file_manager.delete_file(username, file_path)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"Error deleting file: {e}")
return jsonify({"error": str(e)}), 500
@@ -2206,6 +2220,8 @@ def list_files(username):
folder = request.args.get('folder', '')
result = file_manager.list_files(username, folder)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"Error listing files: {e}")
return jsonify({"error": str(e)}), 500
@@ -2915,4 +2931,5 @@ def remove_volume(name):
return jsonify({'removed': success})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=3000, debug=True)
debug = os.environ.get('FLASK_DEBUG', '0') == '1'
app.run(host='0.0.0.0', port=3000, debug=debug)