fix: full security audit remediation — P0/P1/P2/P3 fixes + 1020 passing tests
P0 — Broken functionality: - Fix 12+ endpoints with wrong manager method signatures (email/calendar/file/routing) - Fix email_manager.delete_email_user() missing domain arg - Fix cell-link DNS forwarding wiped on every peer change (generate_corefile now accepts cell_links param; add/remove_cell_dns_forward no longer clobber the file) - Fix Flask SECRET_KEY regenerating on every restart (persisted to DATA_DIR) - Fix _next_peer_ip exhaustion returning 500 instead of 409 - Fix ConfigManager Caddyfile path (/app/config-caddy/) - Fix UI double-add and wrong-key peer bugs in Peers.jsx / WireGuard.jsx - Remove hardcoded credentials from Dashboard.jsx P1 — Security: - CSRF token validation on all POST/PUT/DELETE/PATCH to /api/* (double-submit pattern) - enforce_auth: 503 only when users file readable but empty; never bypass on IOError - WireGuard add_cell_peer: validate pubkey, name, endpoint against strict regexes - DNS add_cell_dns_forward: validate IP and domain; reject injection chars - DNS zone write: realpath containment + record content validation - iptables comment /32 suffix prevents substring match deleting wrong peer rules - is_local_request() trusts only loopback + 172.16.0.0/12 (Docker bridge) - POST /api/containers: volume allow-list prevents arbitrary host mounts - file_manager: bcrypt ($2b→$2y) for WebDAV; realpath containment in delete_user - email/calendar: stop persisting plaintext passwords in user records - routing_manager: validate IPs, networks, and interface names - peer_registry: write peers.json at mode 0o600 - vault_manager: Fernet key file at mode 0o600 - CORS: lock down to explicit origin list - domain/cell_name validation: reject newline, brace, semicolon injection chars P2 — Architecture: - Peer add: rollback registry entry if firewall rules fail post-add - restart_service(): base class now calls _restart_container(); email and calendar managers call cell-mail / cell-radicale respectively - email/calendar managers sync user list (no passwords) to cell_config.json - Pending-restart flag cleared only after helper subprocess exits with code 0 - docker-compose.yml: add config-caddy volume to API container P3 — Tests (854 → 1020): - Fill test_email_endpoints.py, test_calendar_endpoints.py, test_network_endpoints.py, test_routing_endpoints.py - New: test_peer_management_update.py, test_peer_management_edge_cases.py, test_input_validation.py, test_enforce_auth_configured.py, test_cell_link_dns.py, test_logs_endpoints.py, test_cells_endpoints.py, test_is_local_request_per_endpoint.py, test_caddy_routing.py - E2E conftest: skip WireGuard suite when wg-quick absent - Update existing tests to match fixed signatures and comment formats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import os
|
||||
import shutil
|
||||
import pytest
|
||||
import tempfile
|
||||
import secrets
|
||||
from helpers.wg_runner import WGInterface, build_wg_config, cleanup_stale_e2e_interfaces
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
if not shutil.which('wg-quick'):
|
||||
pytest.skip('wg-quick not found — skipping WireGuard E2E tests', allow_module_level=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session', autouse=True)
|
||||
def cleanup_stale_wg_interfaces():
|
||||
cleanup_stale_e2e_interfaces()
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
WireGuard E2E: Caddy per-domain routing correctness.
|
||||
|
||||
Scenarios covered:
|
||||
35. api.<domain> proxies to the API (returns JSON), not the WebUI
|
||||
36. calendar.<domain> via VIP proxies to Radicale, not the WebUI
|
||||
37. files.<domain> via VIP proxies to Filegator, not the WebUI
|
||||
38. mail.<domain> via VIP proxies to Rainloop, not the WebUI
|
||||
39. webdav.<domain> via VIP proxies to the WebDAV service, not the WebUI
|
||||
40. Direct VIP requests (by IP) go to the correct service
|
||||
41. Catch-all :80 serves WebUI for unknown hosts but routes /api/* to API
|
||||
|
||||
The WebUI serves a React app — its HTML starts with '<!doctype html>'.
|
||||
Any service domain that returns that string is incorrectly falling through
|
||||
to the catch-all :80 block instead of being routed by its Host header.
|
||||
|
||||
These tests require a live PIC stack with WireGuard and are marked `wg`.
|
||||
They run via `make test-e2e-wg` or `pytest tests/e2e/wg/ -m wg`.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wg
|
||||
|
||||
_WEBUI_MARKER = '<!doctype html>'
|
||||
|
||||
|
||||
def _config(admin_client) -> dict:
|
||||
r = admin_client.get('/api/config')
|
||||
return r.json() if r.status_code == 200 else {}
|
||||
|
||||
|
||||
def _domain(admin_client) -> str:
|
||||
return _config(admin_client).get('domain') or 'lan'
|
||||
|
||||
|
||||
def _dns_ip(admin_client) -> str:
|
||||
cfg = _config(admin_client)
|
||||
return cfg.get('service_ips', {}).get('dns') or '172.20.0.3'
|
||||
|
||||
|
||||
def _curl_host(ip: str, host: str, path: str = '/', timeout: int = 8) -> tuple[int, str]:
|
||||
"""
|
||||
Make an HTTP request to `ip` with the given Host header.
|
||||
Returns (http_code, body_snippet).
|
||||
"""
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '--connect-timeout', '5',
|
||||
'-H', f'Host: {host}',
|
||||
'-w', '\n__HTTP_CODE__:%{http_code}',
|
||||
f'http://{ip}{path}'],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
output = result.stdout
|
||||
body = ''
|
||||
code = 0
|
||||
if '__HTTP_CODE__:' in output:
|
||||
parts = output.rsplit('__HTTP_CODE__:', 1)
|
||||
body = parts[0].lower()
|
||||
try:
|
||||
code = int(parts[1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
return code, body
|
||||
|
||||
|
||||
def _curl_domain(host: str, path: str = '/', dns_ip: str = '', timeout: int = 8) -> tuple[int, str]:
|
||||
"""Make an HTTP request using curl's --dns-servers to resolve via CoreDNS."""
|
||||
cmd = ['curl', '-s', '--connect-timeout', '5',
|
||||
'-w', '\n__HTTP_CODE__:%{http_code}',
|
||||
f'http://{host}{path}']
|
||||
if dns_ip:
|
||||
cmd = ['curl', '-s', '--connect-timeout', '5',
|
||||
'--dns-servers', dns_ip,
|
||||
'-w', '\n__HTTP_CODE__:%{http_code}',
|
||||
f'http://{host}{path}']
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
output = result.stdout
|
||||
body = ''
|
||||
code = 0
|
||||
if '__HTTP_CODE__:' in output:
|
||||
parts = output.rsplit('__HTTP_CODE__:', 1)
|
||||
body = parts[0].lower()
|
||||
try:
|
||||
code = int(parts[1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
return code, body
|
||||
|
||||
|
||||
# ── Scenario 35: api.<domain> routes to API ───────────────────────────────────
|
||||
|
||||
def test_api_domain_returns_json_not_webui(connected_peer, admin_client):
|
||||
"""api.<domain>/api/status must return JSON, not the React WebUI HTML."""
|
||||
dom = _domain(admin_client)
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
code, body = _curl_domain(f'api.{dom}', '/api/status', dns_ip)
|
||||
assert code not in (0, 000), f"curl to api.{dom}/api/status failed (code {code})"
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
f"api.{dom}/api/status returned WebUI HTML — "
|
||||
"Caddy is not routing api.<domain> to the API; "
|
||||
"check that the http://api.<domain> block exists in the Caddyfile "
|
||||
"and uses the configured domain (not a stale .cell or .dev TLD)"
|
||||
)
|
||||
assert '{' in body or '"' in body, (
|
||||
f"api.{dom}/api/status did not return JSON (body: {body[:100]!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_api_vip_host_header_routes_to_api(connected_peer, admin_client):
|
||||
"""Caddy routes api.<domain> by Host header even when accessed via the Caddy VIP."""
|
||||
dom = _domain(admin_client)
|
||||
code, body = _curl_host('172.20.0.2', f'api.{dom}', '/api/status')
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
f"Host: api.{dom} via 172.20.0.2 returned WebUI HTML — "
|
||||
"Caddy http://api.<domain> block is missing or uses wrong TLD"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 36: calendar.<domain> routes to Radicale ────────────────────────
|
||||
|
||||
def test_calendar_vip_does_not_serve_webui(connected_peer, admin_client):
|
||||
"""calendar.<domain> (VIP 172.20.0.21) must proxy to Radicale, not the WebUI."""
|
||||
dom = _domain(admin_client)
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
code, body = _curl_domain(f'calendar.{dom}', '/', dns_ip)
|
||||
assert code not in (0,), f"curl to calendar.{dom} failed completely"
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
f"calendar.{dom} returned WebUI HTML — "
|
||||
"Caddy is not routing calendar.<domain> to Radicale. "
|
||||
"This happens when Caddy has old (e.g. .cell) domain blocks and all "
|
||||
"traffic falls through to the catch-all :80 block."
|
||||
)
|
||||
|
||||
|
||||
def test_calendar_vip_ip_does_not_serve_webui(connected_peer):
|
||||
"""Direct request to VIP 172.20.0.21 must NOT return the WebUI."""
|
||||
code, body = _curl_host('172.20.0.21', 'calendar.lan')
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
"172.20.0.21 (calendar VIP) returned WebUI HTML — "
|
||||
"Caddy http://calendar.<domain>, http://172.20.0.21:80 block is missing or stale"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 37: files.<domain> routes to Filegator ──────────────────────────
|
||||
|
||||
def test_files_vip_does_not_serve_webui(connected_peer, admin_client):
|
||||
"""files.<domain> (VIP 172.20.0.22) must proxy to Filegator, not the WebUI."""
|
||||
dom = _domain(admin_client)
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
code, body = _curl_domain(f'files.{dom}', '/', dns_ip)
|
||||
assert code not in (0,), f"curl to files.{dom} failed completely"
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
f"files.{dom} returned WebUI HTML — "
|
||||
"Caddy is not routing files.<domain> to Filegator. "
|
||||
"Check the http://files.<domain>, http://172.20.0.22:80 Caddyfile block."
|
||||
)
|
||||
|
||||
|
||||
def test_files_vip_ip_does_not_serve_webui(connected_peer):
|
||||
"""Direct request to VIP 172.20.0.22 must NOT return the WebUI."""
|
||||
code, body = _curl_host('172.20.0.22', 'files.lan')
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
"172.20.0.22 (files VIP) returned WebUI HTML — "
|
||||
"Caddy http://files.<domain>, http://172.20.0.22:80 block is missing or stale"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 38: mail.<domain> routes to Rainloop ────────────────────────────
|
||||
|
||||
def test_mail_vip_does_not_serve_webui(connected_peer, admin_client):
|
||||
"""mail.<domain> (VIP 172.20.0.23) must proxy to Rainloop, not the WebUI."""
|
||||
dom = _domain(admin_client)
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
code, body = _curl_domain(f'mail.{dom}', '/', dns_ip)
|
||||
assert code not in (0,), f"curl to mail.{dom} failed completely"
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
f"mail.{dom} returned WebUI HTML — "
|
||||
"Caddy is not routing mail.<domain> to Rainloop."
|
||||
)
|
||||
|
||||
|
||||
def test_webmail_vip_does_not_serve_webui(connected_peer, admin_client):
|
||||
"""webmail.<domain> (alias, same VIP 172.20.0.23) must NOT return the WebUI."""
|
||||
dom = _domain(admin_client)
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
code, body = _curl_domain(f'webmail.{dom}', '/', dns_ip)
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
f"webmail.{dom} returned WebUI HTML — "
|
||||
"Caddy http://webmail.<domain> block is missing or stale"
|
||||
)
|
||||
|
||||
|
||||
def test_mail_vip_ip_does_not_serve_webui(connected_peer):
|
||||
"""Direct request to VIP 172.20.0.23 must NOT return the WebUI."""
|
||||
code, body = _curl_host('172.20.0.23', 'mail.lan')
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
"172.20.0.23 (mail VIP) returned WebUI HTML — "
|
||||
"Caddy http://mail.<domain>, http://172.20.0.23:80 block is missing or stale"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 39: webdav.<domain> routes to WebDAV ────────────────────────────
|
||||
|
||||
def test_webdav_vip_does_not_serve_webui(connected_peer, admin_client):
|
||||
"""webdav.<domain> (VIP 172.20.0.24) must proxy to the WebDAV service."""
|
||||
dom = _domain(admin_client)
|
||||
dns_ip = _dns_ip(admin_client)
|
||||
code, body = _curl_domain(f'webdav.{dom}', '/', dns_ip)
|
||||
assert code not in (0,), f"curl to webdav.{dom} failed completely"
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
f"webdav.{dom} returned WebUI HTML — "
|
||||
"Caddy is not routing webdav.<domain> to the WebDAV service."
|
||||
)
|
||||
|
||||
|
||||
def test_webdav_vip_ip_does_not_serve_webui(connected_peer):
|
||||
"""Direct request to VIP 172.20.0.24 must NOT return the WebUI."""
|
||||
code, body = _curl_host('172.20.0.24', 'webdav.lan')
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
"172.20.0.24 (webdav VIP) returned WebUI HTML — "
|
||||
"Caddy http://webdav.<domain>, http://172.20.0.24:80 block is missing or stale"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 40: VIP IPs without Host header ─────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize('vip,expected_not', [
|
||||
('172.20.0.21', _WEBUI_MARKER),
|
||||
('172.20.0.22', _WEBUI_MARKER),
|
||||
('172.20.0.23', _WEBUI_MARKER),
|
||||
('172.20.0.24', _WEBUI_MARKER),
|
||||
])
|
||||
def test_vip_direct_access_not_webui(connected_peer, vip, expected_not):
|
||||
"""Each service VIP accessed directly (no special Host) must not return WebUI."""
|
||||
code, body = _curl_host(vip, vip)
|
||||
assert expected_not not in body, (
|
||||
f"VIP {vip} returned WebUI HTML — "
|
||||
"Caddy catch-all :80 is taking over; the per-VIP blocks must listen on port 80"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario 41: Catch-all :80 routes API path correctly ─────────────────────
|
||||
|
||||
def test_catchall_api_path_returns_json(connected_peer):
|
||||
"""The catch-all :80 block must route /api/* to the API (not WebUI)."""
|
||||
code, body = _curl_host('172.20.0.2', 'localhost', '/api/status')
|
||||
assert _WEBUI_MARKER not in body, (
|
||||
"Catch-all :80 returned WebUI HTML for /api/status — "
|
||||
"the `handle /api/*` directive in the :80 block is missing or wrong"
|
||||
)
|
||||
assert '{' in body or '"' in body, (
|
||||
f"/api/status via catch-all did not return JSON (body: {body[:100]!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_catchall_root_serves_webui(connected_peer):
|
||||
"""The catch-all :80 block serves the WebUI for the root path."""
|
||||
code, body = _curl_host('172.20.0.2', 'localhost', '/')
|
||||
assert _WEBUI_MARKER in body, (
|
||||
"Catch-all :80 / did not return WebUI HTML — "
|
||||
"something is broken with the catch-all :80 block"
|
||||
)
|
||||
|
||||
|
||||
# ── Scenario extra: stale TLD detection ──────────────────────────────────────
|
||||
|
||||
def test_caddy_does_not_route_cell_tld(connected_peer):
|
||||
"""Caddy must NOT have active routing for .cell domains — they are from old config."""
|
||||
code, body = _curl_host('172.20.0.2', 'calendar.cell', '/')
|
||||
assert _WEBUI_MARKER in body or code in (0, 404, 502, 503), (
|
||||
"Caddy is still routing calendar.cell — stale .cell blocks remain in config. "
|
||||
"Check that write_caddyfile() is writing to the correct path that Caddy reads."
|
||||
)
|
||||
Reference in New Issue
Block a user