diff --git a/app/auth.py b/app/auth.py index e69efca..8ba01df 100644 --- a/app/auth.py +++ b/app/auth.py @@ -11,10 +11,10 @@ from pathlib import Path from typing import Any +import bcrypt as _bcrypt from fastapi import Request from fastapi.responses import RedirectResponse from itsdangerous import BadSignature, URLSafeTimedSerializer -from passlib.hash import bcrypt from app import fleet_key as _fleet_key_mod @@ -78,12 +78,13 @@ def _resolve_secret_key() -> str: def hash_password(password: str) -> str: # bcrypt enforces a 72-byte limit; truncate UTF-8 bytes (not characters) - # to avoid ValueError on strict backends and passlib/bcrypt >=4.1 compat issues - return bcrypt.hash(password.encode("utf-8")[:72]) + pw = password.encode("utf-8")[:72] + return _bcrypt.hashpw(pw, _bcrypt.gensalt()).decode("ascii") def verify_password(password: str, hashed: str) -> bool: - return bcrypt.verify(password.encode("utf-8")[:72], hashed) + pw = password.encode("utf-8")[:72] + return _bcrypt.checkpw(pw, hashed.encode("ascii")) def create_session_token(user_id: int, username: str, role: str) -> str: @@ -120,13 +121,20 @@ def get_current_user(request: Request) -> dict[str, Any] | None: return decode_session_token(token) +_SECURE_COOKIE = os.getenv("CASHPILOT_SECURE_COOKIE", "auto").lower() + + def set_session_cookie(response: RedirectResponse, token: str) -> RedirectResponse: + use_secure = _SECURE_COOKIE == "true" or ( + _SECURE_COOKIE == "auto" and os.getenv("CASHPILOT_BASE_URL", "").startswith("https") + ) response.set_cookie( SESSION_COOKIE, token, max_age=SESSION_MAX_AGE, httponly=True, samesite="lax", + secure=use_secure, ) return response diff --git a/app/database.py b/app/database.py index 4124fca..eefb036 100644 --- a/app/database.py +++ b/app/database.py @@ -227,7 +227,7 @@ async def upsert_earnings( date: str | None = None, ) -> None: """Insert or update an earnings record for a platform + date.""" - date = date or datetime.utcnow().strftime("%Y-%m-%d") + date = date or datetime.now(datetime.UTC).strftime("%Y-%m-%d") db = await _get_db() try: await db.execute( @@ -305,9 +305,9 @@ async def get_earnings_dashboard_summary() -> dict[str, Any]: """Return aggregated earnings stats for the dashboard.""" db = await _get_db() try: - today = datetime.utcnow().strftime("%Y-%m-%d") - yesterday = (datetime.utcnow() - timedelta(days=1)).strftime("%Y-%m-%d") - first_of_month = datetime.utcnow().replace(day=1).strftime("%Y-%m-%d") + today = datetime.now(datetime.UTC).strftime("%Y-%m-%d") + yesterday = (datetime.now(datetime.UTC) - timedelta(days=1)).strftime("%Y-%m-%d") + first_of_month = datetime.now(datetime.UTC).replace(day=1).strftime("%Y-%m-%d") # Total: sum of latest balance per platform (USD only for now) cursor = await db.execute( @@ -377,7 +377,7 @@ async def get_earnings_dashboard_summary() -> dict[str, Any]: month_earned = max(0.0, row["earned"]) # Yesterday's delta for percentage change - day_before = (datetime.utcnow() - timedelta(days=2)).strftime("%Y-%m-%d") + day_before = (datetime.now(datetime.UTC) - timedelta(days=2)).strftime("%Y-%m-%d") cursor = await db.execute( """ SELECT COALESCE(SUM(y.balance - COALESCE(dy.balance, 0)), 0) as earned @@ -472,7 +472,7 @@ async def get_daily_earnings(days: int = 7) -> list[dict[str, Any]]: balance_by_date[row["date"]] = row["total_balance"] # Generate result for exactly `days` days - now = datetime.utcnow() + now = datetime.now(datetime.UTC) result = [] for i in range(days - 1, -1, -1): d = now - timedelta(days=i) diff --git a/app/exchange_rates.py b/app/exchange_rates.py index 84b3afa..fd22fd4 100644 --- a/app/exchange_rates.py +++ b/app/exchange_rates.py @@ -94,4 +94,7 @@ def to_usd(amount: float, currency: str) -> float | None: return amount if currency in _crypto_usd: return amount * _crypto_usd[currency] + # Fiat: _fiat_rates stores USD->X rates, so divide to get X->USD + if currency in _fiat_rates and _fiat_rates[currency] > 0: + return amount / _fiat_rates[currency] return None diff --git a/app/main.py b/app/main.py index a52ee2c..95241a2 100644 --- a/app/main.py +++ b/app/main.py @@ -8,11 +8,15 @@ import asyncio import contextlib +import ipaddress import json import logging import os +import re from contextlib import asynccontextmanager +from datetime import datetime, timedelta from typing import Any +from urllib.parse import urlparse import httpx from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -21,6 +25,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel +from starlette.middleware.base import BaseHTTPMiddleware from app import auth, catalog, compose_generator, database, exchange_rates, fleet_key @@ -159,7 +164,9 @@ async def _run_collection() -> None: config = await database.get_config() or {} if not isinstance(config, dict): config = {} - collectors = __import__("app.collectors", fromlist=["make_collectors"]).make_collectors(deployments, config) + from app.collectors import make_collectors + + collectors = make_collectors(deployments, config) alerts: list[dict[str, str]] = [] for collector in collectors: result = await collector.collect() @@ -197,12 +204,10 @@ async def _check_stale_workers() -> None: """Mark workers as offline if they haven't sent a heartbeat recently.""" try: workers = await database.list_workers() - from datetime import datetime, timedelta - - cutoff = datetime.utcnow() - timedelta(seconds=STALE_WORKER_SECONDS) + cutoff = datetime.now(datetime.UTC) - timedelta(seconds=STALE_WORKER_SECONDS) for w in workers: if w["status"] == "online" and w.get("last_heartbeat"): - last = datetime.fromisoformat(w["last_heartbeat"]) + last = datetime.fromisoformat(w["last_heartbeat"]).replace(tzinfo=datetime.UTC) if last < cutoff: await database.set_worker_status(w["id"], "offline") logger.info("Worker '%s' marked offline (last heartbeat: %s)", w["name"], w["last_heartbeat"]) @@ -245,6 +250,20 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) + +# Security headers middleware +class _SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + return response + + +app.add_middleware(_SecurityHeadersMiddleware) + # Static files and templates app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") @@ -384,6 +403,22 @@ async def do_register( if not user or user.get("r") != "owner": raise HTTPException(status_code=403, detail="Only owners can add users") + if not re.match(r"^[a-zA-Z0-9_-]{3,32}$", username): + return templates.TemplateResponse( + request, + "auth.html", + { + "title": "Create Account" if is_first else "Add User", + "subtitle": "Create the first admin account" if is_first else "Add a new user", + "mode": "register", + "action": "/register", + "button_text": "Create Account", + "error": "Username must be 3-32 alphanumeric characters (a-z, 0-9, _ -)", + "is_first": is_first, + }, + status_code=400, + ) + if password != password_confirm: return templates.TemplateResponse( request, @@ -400,7 +435,7 @@ async def do_register( status_code=400, ) - if len(password) < 6: + if len(password) < 8: return templates.TemplateResponse( request, "auth.html", @@ -410,7 +445,7 @@ async def do_register( "mode": "register", "action": "/register", "button_text": "Create Account", - "error": "Password must be at least 6 characters", + "error": "Password must be at least 8 characters", "is_first": is_first, }, status_code=400, @@ -749,8 +784,6 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id raise HTTPException(status_code=400, detail=f"Service '{slug}' has no Docker image") # Build full env: YAML defaults + {hostname} substitution + user overrides - import re - hn = body.hostname or HOSTNAME_PREFIX env: dict[str, str] = {} for var in docker_conf.get("env", []): @@ -834,21 +867,47 @@ async def api_remove(request: Request, slug: str, worker_id: int | None = None) # Helpers: proxy commands / logs to worker nodes # --------------------------------------------------------------------------- +_ALLOWED_WORKER_SCHEMES = {"http", "https"} -async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict[str, str]: - """Forward a container command (restart/stop/start/remove) to a worker.""" - worker = await database.get_worker(worker_id) + +def _validate_worker_url(raw_url: str) -> str: + """Validate and return a safe worker URL; raise 400 on SSRF-risky targets.""" + parsed = urlparse(raw_url) + if parsed.scheme not in _ALLOWED_WORKER_SCHEMES: + raise HTTPException(status_code=400, detail=f"Invalid worker URL scheme: {parsed.scheme}") + host = parsed.hostname or "" + if not host: + raise HTTPException(status_code=400, detail="Worker URL has no host") + try: + addr = ipaddress.ip_address(host) + if addr.is_loopback or addr.is_link_local: + raise HTTPException(status_code=400, detail="Worker URL points to loopback/link-local address") + except ValueError: + # hostname, not IP — allow (e.g. tailscale DNS names) + if host in ("localhost", "localhost.localdomain"): + raise HTTPException(status_code=400, detail="Worker URL points to localhost") + return raw_url.rstrip("/") + + +def _get_verified_worker_url(worker: dict[str, Any]) -> tuple[str, dict[str, str]]: + """Validate a worker record and return (url, headers).""" if not worker: raise HTTPException(status_code=404, detail="Worker not found") if worker["status"] != "online": raise HTTPException(status_code=503, detail="Worker is offline") if not worker["url"]: raise HTTPException(status_code=503, detail="Worker URL not known") - - url = worker["url"].rstrip("/") - headers = {} + url = _validate_worker_url(worker["url"]) + headers: dict[str, str] = {} if FLEET_API_KEY: headers["Authorization"] = f"Bearer {FLEET_API_KEY}" + return url, headers + + +async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict[str, str]: + """Forward a container command (restart/stop/start/remove) to a worker.""" + worker = await database.get_worker(worker_id) + url, headers = _get_verified_worker_url(worker) try: async with httpx.AsyncClient(timeout=30) as client: @@ -871,17 +930,7 @@ async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any]) -> dict[str, Any]: """Forward a deploy command with full spec to a worker.""" worker = await database.get_worker(worker_id) - if not worker: - raise HTTPException(status_code=404, detail="Worker not found") - if worker["status"] != "online": - raise HTTPException(status_code=503, detail="Worker is offline") - if not worker["url"]: - raise HTTPException(status_code=503, detail="Worker URL not known") - - url = worker["url"].rstrip("/") - headers = {} - if FLEET_API_KEY: - headers["Authorization"] = f"Bearer {FLEET_API_KEY}" + url, headers = _get_verified_worker_url(worker) try: async with httpx.AsyncClient(timeout=60) as client: @@ -901,17 +950,7 @@ async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any]) async def _proxy_worker_logs(worker_id: int, slug: str, lines: int = 50) -> dict[str, str]: """Forward a logs request to a worker.""" worker = await database.get_worker(worker_id) - if not worker: - raise HTTPException(status_code=404, detail="Worker not found") - if worker["status"] != "online": - raise HTTPException(status_code=503, detail="Worker is offline") - if not worker["url"]: - raise HTTPException(status_code=503, detail="Worker URL not known") - - url = worker["url"].rstrip("/") - headers = {} - if FLEET_API_KEY: - headers["Authorization"] = f"Bearer {FLEET_API_KEY}" + url, headers = _get_verified_worker_url(worker) try: async with httpx.AsyncClient(timeout=30) as client: diff --git a/app/templates/base.html b/app/templates/base.html index 106c201..adac27e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -182,7 +182,7 @@ {% if user %} {% endif %} - + {% block scripts %}{% endblock %} diff --git a/app/worker_api.py b/app/worker_api.py index fa01d21..e0dfcb3 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -19,6 +19,7 @@ import platform import socket from contextlib import asynccontextmanager +from html import escape as _esc from typing import Any import httpx @@ -181,9 +182,9 @@ async def worker_status_page(): status_color = "#22c55e" if c.get("status") == "running" else "#ef4444" container_rows += f""" - {c.get("slug", "unknown")} - {c.get("status", "unknown")} - {c.get("image", "")} + {_esc(str(c.get("slug", "unknown")))} + {_esc(str(c.get("status", "unknown")))} + {_esc(str(c.get("image", "")))} {c.get("cpu_percent", 0)}% {c.get("memory_mb", 0)} MB """ @@ -192,9 +193,9 @@ async def worker_status_page(): container_rows = 'No managed containers' ui_status = ( - f'Connected to {UI_URL}' + f'Connected to {_esc(UI_URL)}' if _ui_connected - else 'Disconnected' + (f" — {_last_error}" if _last_error else "") + else 'Disconnected' + (f" — {_esc(_last_error)}" if _last_error else "") ) return f""" @@ -202,7 +203,7 @@ async def worker_status_page(): - CashPilot Worker — {WORKER_NAME} + CashPilot Worker — {_esc(WORKER_NAME)}