Skip to content

Commit d2e29ec

Browse files
authored
security: comprehensive audit and hardening (v0.2.49)
## Summary Full security audit and hardening of CashPilot across 18 commits: **Security** - Atomic shared fleet key generation (O_CREAT | O_EXCL) — eliminates skip-auth, ephemeral key mismatch, and worker impersonation - Worker port no longer published in default Docker Compose - Bearer auth split: CASHPILOT_ADMIN_API_KEY (owner) + fleet key (writer) - Owner self-demotion and last-owner removal guards - PRAGMA foreign_keys=ON, secret_key redaction **RBAC & UI** - Role-aware gating across dashboard, fleet, settings, service detail, and onboarding - Collector alerts non-clickable for non-owners (no /settings dead-end) **Bug fixes** - Zero-threshold payout eligibility, Storj default URL, port protocol preservation - Partial preference updates, catalog cache mutation, auto-resolve worker_id **Tests** - 14 integration tests for eligibility (real handler, mocked deps) - 12 regression tests for fleet key bootstrap and Storj config Closes #10.
1 parent 5790381 commit d2e29ec

22 files changed

Lines changed: 779 additions & 99 deletions

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414

1515
- uses: actions/setup-python@v5
1616
with:
17-
python-version: "3.12"
17+
python-version: "3.14"
1818

1919
- name: Install dependencies
2020
run: |

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,40 @@
22

33
All notable changes to CashPilot are documented here.
44

5+
## [0.2.49] - 2026-03-31
6+
7+
### Security
8+
- Fix unauthenticated worker-control exposure on default Docker Compose (worker port no longer published)
9+
- Atomic shared fleet key generation with `O_CREAT | O_EXCL` — eliminates skip-auth, ephemeral key mismatch, and worker impersonation vectors
10+
- Bearer auth split: `CASHPILOT_ADMIN_API_KEY` for owner-level, fleet key for writer-level API access
11+
- Worker heartbeat URL pinned to prevent spoofing in no-key mode
12+
- Fleet key first-boot race condition closed with retry-read backoff
13+
- Credential encryption key (`secret_key`) added to secret config redaction
14+
- `PRAGMA foreign_keys=ON` enforced for SQLite CASCADE integrity
15+
16+
### Fixed
17+
- Zero-threshold payout: services with `min_amount: 0` are now correctly eligible when balance > 0
18+
- Storj collector no longer requires manual `api_url` setting — uses built-in default
19+
- Owner self-demotion and last-owner removal guards on `PATCH /api/users/{id}`
20+
- Viewer/writer role gating on dashboard controls (restart, stop, logs), settings sidebar, fleet page, and service detail modal
21+
- Onboarding step 4 CTAs no longer link non-owners to the owner-only settings page
22+
- Collector alert clicks are no-op for non-owners (no /settings dead-end)
23+
- Partial preference updates (nullable fields merged with existing)
24+
- Port parsing preserves TCP/UDP protocol for Docker SDK
25+
- Auto-resolve `worker_id` when only one worker is online
26+
- Catalog cache returns shallow copies to prevent cross-request mutation
27+
- CSS `var(--danger)` replaced with `var(--error)` for deploy failure styling
28+
- Bytelixir API fallback clearly reports HTML scrape failure
29+
- Worker URL override via `CASHPILOT_WORKER_URL` env var
30+
- Fleet page copy-to-clipboard fetches key before copying
31+
32+
### Added
33+
- `app/fleet_key.py` — central fleet key resolution module (env var → shared file → auto-generate)
34+
- `CASHPILOT_WORKER_URL` env var for explicit worker URL override
35+
- `cashpilot_fleet` shared Docker volume for fleet key exchange
36+
- Integration tests for payout eligibility (14 tests against real handler)
37+
- Regression tests for Storj optional `api_url` and fleet key bootstrap (12 tests)
38+
539
## [0.2.17] - 2026-03-28
640

741
### Fixed

app/auth.py

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,71 @@
55

66
from __future__ import annotations
77

8+
import logging
89
import os
10+
import secrets
11+
from pathlib import Path
912
from typing import Any
1013

1114
from fastapi import Request
1215
from fastapi.responses import RedirectResponse
1316
from itsdangerous import BadSignature, URLSafeTimedSerializer
1417
from passlib.hash import bcrypt
1518

16-
SECRET_KEY = os.getenv("CASHPILOT_SECRET_KEY", "changeme-generate-a-random-secret")
19+
from app import fleet_key as _fleet_key_mod
20+
21+
_logger = logging.getLogger(__name__)
22+
23+
_KNOWN_DEFAULTS = {
24+
"changeme-generate-a-random-secret",
25+
"changeme",
26+
"",
27+
}
28+
29+
30+
def _resolve_secret_key() -> str:
31+
"""Return a cryptographically safe secret key.
32+
33+
Priority:
34+
1. CASHPILOT_SECRET_KEY env var (if not a known default)
35+
2. Persisted key in <data_dir>/.secret_key
36+
3. Generate, persist, and return a new random key
37+
"""
38+
env_key = os.getenv("CASHPILOT_SECRET_KEY", "")
39+
if env_key and env_key not in _KNOWN_DEFAULTS:
40+
return env_key
41+
42+
if env_key in _KNOWN_DEFAULTS and env_key:
43+
_logger.warning(
44+
"CASHPILOT_SECRET_KEY is set to a known default — ignoring it. "
45+
"Set a strong random value or remove it to auto-generate."
46+
)
47+
48+
# Try to read persisted key
49+
data_dir = Path(os.getenv("CASHPILOT_DATA_DIR", "/data"))
50+
key_file = data_dir / ".secret_key"
51+
try:
52+
if key_file.is_file():
53+
stored = key_file.read_text().strip()
54+
if stored and stored not in _KNOWN_DEFAULTS:
55+
return stored
56+
except OSError:
57+
pass
58+
59+
# Generate and persist
60+
new_key = secrets.token_urlsafe(48)
61+
try:
62+
data_dir.mkdir(parents=True, exist_ok=True)
63+
key_file.write_text(new_key)
64+
key_file.chmod(0o600)
65+
_logger.info("Generated and persisted new secret key to %s", key_file)
66+
except OSError as exc:
67+
_logger.warning("Could not persist secret key to %s: %s", key_file, exc)
68+
69+
return new_key
70+
71+
72+
SECRET_KEY = _resolve_secret_key()
1773
SESSION_COOKIE = "cashpilot_session"
1874
SESSION_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
1975

@@ -46,12 +102,15 @@ def get_current_user(request: Request) -> dict[str, Any] | None:
46102
Checks Authorization header first (for programmatic access like Home Assistant),
47103
then falls back to session cookie (for browser sessions).
48104
"""
49-
# Check Bearer token against CASHPILOT_API_KEY
50-
api_key = os.getenv("CASHPILOT_API_KEY", "")
51-
if api_key:
52-
auth_header = request.headers.get("Authorization", "")
53-
if auth_header == f"Bearer {api_key}":
105+
# Check Bearer token — admin key gets owner, fleet key gets writer
106+
auth_header = request.headers.get("Authorization", "")
107+
if auth_header:
108+
admin_key = os.getenv("CASHPILOT_ADMIN_API_KEY", "")
109+
if admin_key and auth_header == f"Bearer {admin_key}":
54110
return {"uid": 0, "u": "api", "r": "owner"}
111+
resolved_fleet_key = _fleet_key_mod.resolve_fleet_key()
112+
if resolved_fleet_key and auth_header == f"Bearer {resolved_fleet_key}":
113+
return {"uid": 0, "u": "api", "r": "writer"}
55114

56115
# Fall back to session cookie
57116
token = request.cookies.get(SESSION_COOKIE)

app/catalog.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,10 @@ def load_services() -> list[dict[str, Any]]:
9494

9595

9696
def get_services() -> list[dict[str, Any]]:
97-
"""Return cached services (load first if empty)."""
97+
"""Return shallow copies of cached services (safe to mutate per-request)."""
9898
if not _services:
9999
load_services()
100-
return _services
100+
return [dict(s) for s in _services]
101101

102102

103103
def get_services_by_category() -> dict[str, list[dict[str, Any]]]:
@@ -110,10 +110,11 @@ def get_services_by_category() -> dict[str, list[dict[str, Any]]]:
110110

111111

112112
def get_service(slug: str) -> dict[str, Any] | None:
113-
"""Look up a single service by slug."""
113+
"""Look up a single service by slug (returns a shallow copy)."""
114114
if not _by_slug:
115115
load_services()
116-
return _by_slug.get(slug)
116+
svc = _by_slug.get(slug)
117+
return dict(svc) if svc else None
117118

118119

119120
def _sighup_handler(signum: int, frame: Any) -> None:

app/collectors/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@
4949
"earnapp": ["oauth_token", "?brd_sess_id"],
5050
"iproyal": ["email", "password"],
5151
"mysterium": ["email", "password"],
52-
"storj": ["api_url"],
53-
"traffmonetizer": ["token"],
52+
"storj": ["?api_url"],
53+
"traffmonetizer": ["?token", "?email", "?password"],
5454
"repocket": ["email", "password"],
5555
"proxyrack": ["api_key"],
5656
"bitping": ["email", "password"],

app/collectors/bytelixir.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ async def collect(self) -> EarningsResult:
188188
data = api_resp.json()
189189

190190
# Response shape: {"data": {"balance": "0.0000000000", ...}}
191+
# NOTE: /api/v1/user returns *withdrawable* balance only, not
192+
# total earned. Flag this so the user knows it's approximate.
191193
user_data = data.get("data", {})
192194
balance_str = user_data.get("balance", "0")
193195
balance = float(balance_str)
@@ -196,6 +198,7 @@ async def collect(self) -> EarningsResult:
196198
platform=self.platform,
197199
balance=round(balance, 4),
198200
currency="USD",
201+
error="Withdrawable balance only (HTML scrape failed, using API fallback)",
199202
)
200203
except Exception as exc:
201204
logger.error("Bytelixir collection failed: %s", exc)

app/collectors/packetstream.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async def collect(self) -> EarningsResult:
4646

4747
# Extract balance from window.userData in the HTML
4848
balance = 0.0
49+
parsed = False
4950
match = re.search(
5051
r"window\.userData\s*=\s*(\{[^}]+\})",
5152
html,
@@ -56,14 +57,25 @@ async def collect(self) -> EarningsResult:
5657
try:
5758
user_data = json.loads(match.group(1))
5859
balance = float(user_data.get("balance", 0))
60+
parsed = True
5961
except (json.JSONDecodeError, ValueError):
6062
pass
6163

6264
# Fallback: look for balance pattern
63-
if balance == 0.0:
65+
if not parsed:
6466
match = re.search(r'"balance"\s*:\s*([\d.]+)', html)
6567
if match:
6668
balance = float(match.group(1))
69+
parsed = True
70+
71+
# If no pattern matched at all, report an error rather than
72+
# silently returning 0 (which hides integration breakage).
73+
if not parsed:
74+
return EarningsResult(
75+
platform=self.platform,
76+
balance=0.0,
77+
error="Could not parse balance from dashboard — page structure may have changed",
78+
)
6779

6880
return EarningsResult(
6981
platform=self.platform,

app/database.py

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,90 @@
77

88
from __future__ import annotations
99

10+
import logging
1011
import os
1112
from datetime import datetime, timedelta
1213
from pathlib import Path
1314
from typing import Any
1415

1516
import aiosqlite
17+
from cryptography.fernet import Fernet, InvalidToken
18+
19+
_logger = logging.getLogger(__name__)
1620

1721
DB_DIR = Path(os.getenv("CASHPILOT_DATA_DIR", "/data"))
1822
DB_PATH = DB_DIR / "cashpilot.db"
1923

24+
# ---------------------------------------------------------------------------
25+
# Credential encryption (Fernet)
26+
# ---------------------------------------------------------------------------
27+
28+
_FERNET_KEY_FILE = DB_DIR / ".fernet_key"
29+
30+
# Keys that contain secrets and must be encrypted at rest
31+
SECRET_CONFIG_KEYS = {
32+
"password",
33+
"token",
34+
"auth_token",
35+
"access_token",
36+
"api_key",
37+
"secret_key",
38+
"session_cookie",
39+
"oauth_token",
40+
"brd_sess_id",
41+
"remember_web",
42+
"xsrf_token",
43+
}
44+
45+
46+
def _is_secret_key(key: str) -> bool:
47+
"""Return True if a config key holds a secret value (by suffix match)."""
48+
lower = key.lower()
49+
return any(lower.endswith(s) for s in SECRET_CONFIG_KEYS)
50+
51+
52+
def _load_or_create_fernet() -> Fernet:
53+
"""Load or generate the Fernet encryption key."""
54+
try:
55+
if _FERNET_KEY_FILE.is_file():
56+
raw = _FERNET_KEY_FILE.read_text().strip()
57+
if raw:
58+
return Fernet(raw.encode())
59+
except (OSError, ValueError):
60+
pass
61+
62+
key = Fernet.generate_key()
63+
try:
64+
DB_DIR.mkdir(parents=True, exist_ok=True)
65+
_FERNET_KEY_FILE.write_text(key.decode())
66+
_FERNET_KEY_FILE.chmod(0o600)
67+
_logger.info("Generated new Fernet key at %s", _FERNET_KEY_FILE)
68+
except OSError as exc:
69+
_logger.warning("Could not persist Fernet key: %s", exc)
70+
return Fernet(key)
71+
72+
73+
_fernet = _load_or_create_fernet()
74+
75+
_ENC_PREFIX = "enc:"
76+
77+
78+
def encrypt_value(value: str) -> str:
79+
"""Encrypt a string value, returning an 'enc:' prefixed token."""
80+
return _ENC_PREFIX + _fernet.encrypt(value.encode()).decode()
81+
82+
83+
def decrypt_value(value: str) -> str:
84+
"""Decrypt an 'enc:' prefixed token back to plaintext."""
85+
if not value.startswith(_ENC_PREFIX):
86+
return value # Not encrypted (legacy data)
87+
try:
88+
return _fernet.decrypt(value[len(_ENC_PREFIX) :].encode()).decode()
89+
except InvalidToken:
90+
_logger.warning("Failed to decrypt config value — key may have changed")
91+
return ""
92+
93+
2094
_SCHEMA = """
2195
CREATE TABLE IF NOT EXISTS earnings (
2296
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -94,6 +168,7 @@ async def _get_db() -> aiosqlite.Connection:
94168
db = await aiosqlite.connect(str(DB_PATH))
95169
db.row_factory = aiosqlite.Row
96170
await db.execute("PRAGMA journal_mode=WAL")
171+
await db.execute("PRAGMA foreign_keys=ON")
97172
return db
98173

99174

@@ -389,40 +464,48 @@ async def get_daily_earnings(days: int = 7) -> list[dict[str, Any]]:
389464

390465

391466
async def get_config(key: str | None = None) -> dict[str, str] | str | None:
392-
"""Get a single config value (if key given) or all config as a dict."""
467+
"""Get a single config value (if key given) or all config as a dict.
468+
469+
Secret values are decrypted transparently.
470+
"""
393471
db = await _get_db()
394472
try:
395473
if key:
396474
cursor = await db.execute("SELECT value FROM config WHERE key = ?", (key,))
397475
row = await cursor.fetchone()
398-
return row["value"] if row else None
476+
if not row:
477+
return None
478+
val = row["value"]
479+
return decrypt_value(val) if _is_secret_key(key) else val
399480
cursor = await db.execute("SELECT key, value FROM config")
400481
rows = await cursor.fetchall()
401-
return {r["key"]: r["value"] for r in rows}
482+
return {r["key"]: (decrypt_value(r["value"]) if _is_secret_key(r["key"]) else r["value"]) for r in rows}
402483
finally:
403484
await db.close()
404485

405486

406487
async def set_config(key: str, value: str) -> None:
407-
"""Upsert a config key-value pair."""
488+
"""Upsert a config key-value pair. Secrets are encrypted at rest."""
489+
stored = encrypt_value(value) if _is_secret_key(key) else value
408490
db = await _get_db()
409491
try:
410492
await db.execute(
411493
"INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
412-
(key, value),
494+
(key, stored),
413495
)
414496
await db.commit()
415497
finally:
416498
await db.close()
417499

418500

419501
async def set_config_bulk(data: dict[str, str]) -> None:
420-
"""Upsert multiple config entries at once."""
502+
"""Upsert multiple config entries at once. Secrets are encrypted at rest."""
503+
pairs = [(k, encrypt_value(v) if _is_secret_key(k) else v) for k, v in data.items()]
421504
db = await _get_db()
422505
try:
423506
await db.executemany(
424507
"INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
425-
list(data.items()),
508+
pairs,
426509
)
427510
await db.commit()
428511
finally:

0 commit comments

Comments
 (0)