Skip to content

Commit 29c5c30

Browse files
authored
fix: ecosystem audit — auth hardening, compose fixes, fleet docs, XSS, validation (#46)
* fix: address ecosystem audit — security, compose, docs, validation Security: - Fleet key now grants limited 'fleet' role instead of full 'writer' - Deploy endpoint requires owner role (not writer/fleet) - First-run registration restricted to private/loopback networks - XSS fix: fleet page uses data attributes instead of inline onclick - Fleet bootstrap: entrypoint ensures /fleet dir is writable Compose generator: - Named volumes now declared at top-level volumes section - ${VAR} placeholders escaped as $${VAR} to prevent host interpolation Validation: - Deploy API rejects requests with blank required env vars - Android workers: containers array skipped (only apps processed) Docs: - Fleet docs rewritten to match actual REST heartbeat implementation - Presearch/IPRoyal/Earn.fm/Repocket guide env vars aligned with YAML - Earn.fm/Repocket service YAMLs aligned with collector auth models Testing: - Added requirements-dev.txt for local test reproducibility Closes #cp-bj8p #cp-jbkg #cp-370w #cp-3ddy #cp-zvhs #cp-xt5k #cp-zrlr Closes #cp-pkio #cp-v65p #cp-p2sb #cp-hvay #cp-6hr9 #cp-6ho7 * fix: address review findings — extract IP helper, strip whitespace, fix SSRF, align docs - Extract _require_private_network() helper (removes duplication, handles None/ValueError) - Strip whitespace in required env var validation - Fix SSRF in api_worker_command by using _get_verified_worker_url() - Align earnfm/repocket docs with email/password auth model - Fix repocket policy contradiction (VPS accepted at lower rates) * fix: address all review nits — escape idempotency, NaN guard, test coverage - Fix _escape_interpolation to use regex (skips already-escaped $${}) - Add isNaN guard in fleet.html event delegation - Add TestRequireRole covering fleet escalation boundaries - Add TestEscapeInterpolation, TestIsNamedVolume, TestNamedVolumeDeclaration * fix: update tests for auth changes — deploy requires owner, fleet returns fleet role - Fix _require_private_network to allow non-IP hosts (test clients, unix sockets) - Update deploy tests to use owner auth (deploy endpoint now requires owner) - Update auth test to expect fleet role instead of writer - Fix ruff format on compose_generator.py and test_compose.py * style: split long comprehension lines for format compatibility
1 parent 0e17e16 commit 29c5c30

19 files changed

Lines changed: 292 additions & 130 deletions

app/auth.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,15 @@ def get_current_user(request: Request) -> dict[str, Any] | None:
104104
Checks Authorization header first (for programmatic access like Home Assistant),
105105
then falls back to session cookie (for browser sessions).
106106
"""
107-
# Check Bearer token — admin key gets owner, fleet key gets writer
107+
# Check Bearer token — admin key gets owner, fleet key gets fleet (limited)
108108
auth_header = request.headers.get("Authorization", "")
109109
if auth_header:
110110
admin_key = os.getenv("CASHPILOT_ADMIN_API_KEY", "")
111111
if admin_key and auth_header == f"Bearer {admin_key}":
112112
return {"uid": 0, "u": "api", "r": "owner"}
113113
resolved_fleet_key = _fleet_key_mod.resolve_fleet_key()
114114
if resolved_fleet_key and auth_header == f"Bearer {resolved_fleet_key}":
115-
return {"uid": 0, "u": "api", "r": "writer"}
115+
return {"uid": 0, "u": "fleet", "r": "fleet"}
116116

117117
# Fall back to session cookie
118118
token = request.cookies.get(SESSION_COOKIE)
@@ -148,4 +148,8 @@ def require_role(user: dict[str, Any] | None, *roles: str) -> bool:
148148
"""Check if user has one of the required roles."""
149149
if not user:
150150
return False
151-
return user.get("r") in roles
151+
role = user.get("r")
152+
# fleet role implicitly satisfies writer checks (for heartbeat/status endpoints)
153+
if role == "fleet" and "writer" in roles:
154+
return True
155+
return role in roles

app/compose_generator.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from __future__ import annotations
1313

14+
import re
1415
import socket
1516
from typing import Any
1617

@@ -27,6 +28,26 @@
2728
)
2829

2930

31+
def _escape_interpolation(value: str) -> str:
32+
"""Escape ${VAR} as $${VAR} so Docker Compose treats it as literal.
33+
34+
Skips already-escaped sequences ($${). Does not handle bare $VAR (without braces)
35+
since catalog YAML exclusively uses the braced form.
36+
"""
37+
return re.sub(r"(?<!\$)\$\{", "$${", value)
38+
39+
40+
def _is_named_volume(volume_str: str) -> str | None:
41+
"""Return the volume name if the mapping uses a named volume, else None.
42+
43+
Named volumes have a source that doesn't start with /, ., or ~.
44+
"""
45+
source = volume_str.split(":")[0]
46+
if source and not source.startswith(("/", ".", "~")):
47+
return source
48+
return None
49+
50+
3051
def _service_to_compose(
3152
svc: dict[str, Any],
3253
env_vars: dict[str, str] | None = None,
@@ -87,10 +108,10 @@ def _service_to_compose(
87108
if ports:
88109
compose_svc["ports"] = [str(p) for p in ports]
89110

90-
# Volumes
111+
# Volumes — escape ${VAR} interpolation in host paths
91112
volumes = docker_conf.get("volumes", [])
92113
if volumes:
93-
compose_svc["volumes"] = [str(v) for v in volumes]
114+
compose_svc["volumes"] = [_escape_interpolation(str(v)) for v in volumes]
94115

95116
# Network mode
96117
network_mode = docker_conf.get("network_mode")
@@ -102,10 +123,10 @@ def _service_to_compose(
102123
if cap_add:
103124
compose_svc["cap_add"] = cap_add
104125

105-
# Command
126+
# Command — escape ${VAR} interpolation
106127
command = docker_conf.get("command")
107128
if command:
108-
compose_svc["command"] = command
129+
compose_svc["command"] = _escape_interpolation(command)
109130

110131
return compose_svc
111132

@@ -169,6 +190,17 @@ def generate_compose_all(
169190

170191
def _dump_compose(compose: dict, name: str) -> str:
171192
"""Serialize compose dict to YAML with a header comment."""
193+
# Collect named volumes from all services
194+
named_volumes: set[str] = set()
195+
for svc in compose.get("services", {}).values():
196+
for vol in svc.get("volumes", []):
197+
vol_name = _is_named_volume(vol)
198+
if vol_name:
199+
named_volumes.add(vol_name)
200+
201+
if named_volumes:
202+
compose["volumes"] = {v: {} for v in sorted(named_volumes)}
203+
172204
header = (
173205
f"# Generated by CashPilot for: {name}\n"
174206
"# https://github.com/GeiserX/CashPilot\n"

app/fleet_key.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ def resolve_fleet_key() -> str:
6262
pass
6363
time.sleep(0.1)
6464
except OSError as exc:
65-
_logger.warning(
66-
"Could not persist fleet key to %s: %s — set CASHPILOT_API_KEY or mount a shared /fleet volume",
65+
_logger.error(
66+
"Cannot write fleet key to %s: %s. "
67+
"Fix: set CASHPILOT_API_KEY env var explicitly, "
68+
"or fix /fleet volume permissions (chown 1000:0 /fleet on the host).",
6769
_FLEET_KEY_FILE,
6870
exc,
6971
)

app/main.py

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,28 @@ async def _get_all_worker_containers() -> list[dict[str, Any]]:
6262
is_android = sys_info.get("device_type") == "android"
6363
worker_name = w.get("name", "worker")
6464

65-
# Docker containers (from Docker-based workers)
66-
containers = _safe_json(w.get("containers", "[]"))
67-
for c in containers:
68-
slug = c.get("slug", "")
69-
if slug:
70-
result.append(
71-
{
72-
"slug": slug,
73-
"name": c.get("name", slug),
74-
"status": c.get("status", "unknown"),
75-
"image": c.get("image", ""),
76-
"cpu_percent": c.get("cpu_percent", 0),
77-
"memory_mb": c.get("memory_mb", 0),
78-
"category": "",
79-
"deployed_by": worker_name,
80-
"_node": worker_name,
81-
"_worker_id": w.get("id"),
82-
"_has_docker": worker_has_docker,
83-
"_is_android": False,
84-
}
85-
)
65+
# Docker containers (from Docker-based workers only — skip for Android)
66+
if not is_android:
67+
containers = _safe_json(w.get("containers", "[]"))
68+
for c in containers:
69+
slug = c.get("slug", "")
70+
if slug:
71+
result.append(
72+
{
73+
"slug": slug,
74+
"name": c.get("name", slug),
75+
"status": c.get("status", "unknown"),
76+
"image": c.get("image", ""),
77+
"cpu_percent": c.get("cpu_percent", 0),
78+
"memory_mb": c.get("memory_mb", 0),
79+
"category": "",
80+
"deployed_by": worker_name,
81+
"_node": worker_name,
82+
"_worker_id": w.get("id"),
83+
"_has_docker": worker_has_docker,
84+
"_is_android": False,
85+
}
86+
)
8687

8788
# Android apps (from Android workers)
8889
if is_android:
@@ -309,6 +310,18 @@ def _require_owner(request: Request) -> dict[str, Any]:
309310
return user
310311

311312

313+
def _require_private_network(request: Request) -> None:
314+
"""Block requests from public IPs (for first-run setup)."""
315+
if not request.client or not request.client.host:
316+
return
317+
try:
318+
client_ip = ipaddress.ip_address(request.client.host)
319+
except ValueError:
320+
return
321+
if not (client_ip.is_loopback or client_ip.is_private):
322+
raise HTTPException(status_code=403, detail="First-run setup only allowed from private networks")
323+
324+
312325
# ---------------------------------------------------------------------------
313326
# Auth routes
314327
# ---------------------------------------------------------------------------
@@ -373,6 +386,8 @@ async def page_register(request: Request, error: str = ""):
373386
user = auth.get_current_user(request)
374387
if not user or user.get("r") != "owner":
375388
return RedirectResponse("/login", status_code=303)
389+
if is_first:
390+
_require_private_network(request)
376391

377392
return templates.TemplateResponse(
378393
request,
@@ -404,6 +419,9 @@ async def do_register(
404419
if not user or user.get("r") != "owner":
405420
raise HTTPException(status_code=403, detail="Only owners can add users")
406421

422+
if is_first:
423+
_require_private_network(request)
424+
407425
if not re.match(r"^[a-zA-Z0-9_-]{3,32}$", username):
408426
return templates.TemplateResponse(
409427
request,
@@ -773,7 +791,7 @@ class DeployRequest(BaseModel):
773791

774792
@app.post("/api/deploy/{slug}")
775793
async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id: int | None = None) -> dict[str, str]:
776-
_require_writer(request)
794+
_require_owner(request)
777795
worker_id = await _resolve_worker_id(worker_id)
778796
svc = catalog.get_service(slug)
779797
if not svc:
@@ -794,6 +812,15 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id
794812
env[var["key"]] = str(default)
795813
env.update(body.env or {})
796814

815+
# Validate required env vars are not blank
816+
missing = [
817+
var.get("label", var["key"])
818+
for var in docker_conf.get("env", [])
819+
if var.get("required") and not env.get(var["key"], "").strip()
820+
]
821+
if missing:
822+
raise HTTPException(status_code=400, detail=f"Missing required fields: {', '.join(missing)}")
823+
797824
# Ports — key is "container_port/protocol" per Docker SDK
798825
ports: dict[str, int] = {}
799826
for mapping in docker_conf.get("ports", []):
@@ -1651,17 +1678,7 @@ async def api_worker_command(request: Request, worker_id: int, body: WorkerComma
16511678
_require_writer(request)
16521679

16531680
worker = await database.get_worker(worker_id)
1654-
if not worker:
1655-
raise HTTPException(status_code=404, detail="Worker not found")
1656-
if worker["status"] != "online":
1657-
raise HTTPException(status_code=503, detail="Worker is offline")
1658-
if not worker["url"]:
1659-
raise HTTPException(status_code=503, detail="Worker URL not known")
1660-
1661-
url = worker["url"].rstrip("/")
1662-
headers = {}
1663-
if FLEET_API_KEY:
1664-
headers["Authorization"] = f"Bearer {FLEET_API_KEY}"
1681+
url, headers = _get_verified_worker_url(worker)
16651682

16661683
try:
16671684
async with httpx.AsyncClient(timeout=30) as client:

app/templates/fleet.html

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ <h2 class="section-title">Workers</h2>
122122
<span style="font-weight: 600; color: var(--text-primary);">${esc(w.name)}</span>
123123
${isAndroid ? '<span style="font-size:0.7rem; padding:2px 6px; border-radius:4px; background:var(--bg-hover); color:var(--text-muted);">Android</span>' : ''}
124124
</div>
125-
${_isOwner ? `<button class="btn btn-danger btn-sm" onclick="removeWorker(${w.id}, '${esc(w.name)}')" title="Remove worker">Remove</button>` : ''}
125+
${_isOwner ? `<button class="btn btn-danger btn-sm btn-remove-worker" data-worker-id="${w.id}" data-worker-name="${esc(w.name)}" title="Remove worker">Remove</button>` : ''}
126126
</div>
127127
<div style="display: flex; gap: 16px; flex-wrap: wrap; font-size: 0.82rem; color: var(--text-muted);">
128128
<span>${esc(w.url || '-')}</span>
@@ -202,7 +202,7 @@ <h2 class="section-title">Workers</h2>
202202
}
203203
};
204204

205-
window.removeWorker = async function(workerId, name) {
205+
async function removeWorker(workerId, name) {
206206
if (!confirm(`Remove worker "${name}"? This will unregister it from the fleet.`)) return;
207207
try {
208208
await CP.api(`/api/workers/${workerId}`, { method: 'DELETE' });
@@ -211,7 +211,16 @@ <h2 class="section-title">Workers</h2>
211211
} catch (err) {
212212
CP.toast('Failed: ' + err.message, 'error');
213213
}
214-
};
214+
}
215+
216+
document.getElementById('fleet-workers').addEventListener('click', function(e) {
217+
const btn = e.target.closest('.btn-remove-worker');
218+
if (!btn) return;
219+
const id = parseInt(btn.dataset.workerId, 10);
220+
if (isNaN(id)) return;
221+
const name = btn.dataset.workerName;
222+
removeWorker(id, name);
223+
});
215224

216225
window.copyWorkerEnv = async function() {
217226
// Auto-fetch API key if not yet revealed

0 commit comments

Comments
 (0)