From 85455f32717a5341460f6200a06f834d8f1fb42f Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 26 Mar 2026 18:40:41 +0100 Subject: [PATCH] Security hardening: secrets, validation, Docker, and error handling - Add root .gitignore to prevent btc_wallet.py (with RPC credentials) from being committed - Load JWT SECRET_KEY from environment variable instead of hardcoded value - Restrict CORS to explicit methods/headers instead of wildcards - Add Pydantic Field validation (gt=0) to purchase amounts and user credentials - Add logging to all silent exception handlers in btc.py - Run backend and frontend Docker containers as non-root appuser - Add .dockerignore for both backend and frontend - Pass SECRET_KEY env var through docker-compose; add healthchecks to both services - Update bcrypt from pinned 3.2.2 to >=4.0.0 - Capture error objects in frontend catch blocks; check admin delete response Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 ++ btc-portfolio/backend/.dockerignore | 7 +++++++ btc-portfolio/backend/Dockerfile | 6 +++++- btc-portfolio/backend/app/auth.py | 3 ++- btc-portfolio/backend/app/main.py | 4 ++-- btc-portfolio/backend/app/routes/purchases.py | 10 +++++----- btc-portfolio/backend/app/routes/users.py | 6 +++--- btc-portfolio/backend/app/services/btc.py | 12 +++++++++--- btc-portfolio/backend/requirements.txt | 2 +- btc-portfolio/docker-compose.yml | 13 +++++++++++++ btc-portfolio/frontend/.dockerignore | 6 ++++++ btc-portfolio/frontend/Dockerfile | 3 +++ .../frontend/src/components/AddPurchase.js | 3 ++- btc-portfolio/frontend/src/pages/AdminPage.js | 7 ++++++- btc-portfolio/frontend/src/pages/Login.js | 3 ++- btc-portfolio/frontend/src/pages/Register.js | 3 ++- 16 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 .gitignore create mode 100644 btc-portfolio/backend/.dockerignore create mode 100644 btc-portfolio/frontend/.dockerignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82ff38b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Local wallet scripts with credentials — never commit +btc_wallet.py diff --git a/btc-portfolio/backend/.dockerignore b/btc-portfolio/backend/.dockerignore new file mode 100644 index 0000000..9b5dfdb --- /dev/null +++ b/btc-portfolio/backend/.dockerignore @@ -0,0 +1,7 @@ +.git +__pycache__ +*.pyc +*.pyo +.env +*.egg-info +.pytest_cache diff --git a/btc-portfolio/backend/Dockerfile b/btc-portfolio/backend/Dockerfile index 7f2d0b9..7691b6d 100644 --- a/btc-portfolio/backend/Dockerfile +++ b/btc-portfolio/backend/Dockerfile @@ -5,8 +5,12 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser + COPY . . -RUN mkdir -p /app/data +RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data + +USER appuser CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/btc-portfolio/backend/app/auth.py b/btc-portfolio/backend/app/auth.py index 7d1993e..9e3dc53 100644 --- a/btc-portfolio/backend/app/auth.py +++ b/btc-portfolio/backend/app/auth.py @@ -1,8 +1,9 @@ +import os from datetime import datetime, timedelta from jose import JWTError, jwt from passlib.context import CryptContext -SECRET_KEY = "change-me-in-production-use-a-long-random-string" +SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-me") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index 0c2d4f0..b458409 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -13,8 +13,8 @@ app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://localhost:3001"], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], ) app.include_router(users.router) diff --git a/btc-portfolio/backend/app/routes/purchases.py b/btc-portfolio/backend/app/routes/purchases.py index bad5c7a..f8c3c65 100644 --- a/btc-portfolio/backend/app/routes/purchases.py +++ b/btc-portfolio/backend/app/routes/purchases.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import List from datetime import datetime @@ -12,13 +12,13 @@ router = APIRouter() class PurchaseCreate(BaseModel): - amount_eur: float - price_eur: float + amount_eur: float = Field(gt=0, le=10_000_000) + price_eur: float = Field(gt=0, le=10_000_000) class PurchaseUpdate(BaseModel): - amount_eur: float - price_eur: float + amount_eur: float = Field(gt=0, le=10_000_000) + price_eur: float = Field(gt=0, le=10_000_000) created_at: datetime diff --git a/btc-portfolio/backend/app/routes/users.py b/btc-portfolio/backend/app/routes/users.py index d78d118..eb2a640 100644 --- a/btc-portfolio/backend/app/routes/users.py +++ b/btc-portfolio/backend/app/routes/users.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from pydantic import BaseModel +from pydantic import BaseModel, Field from ..database import get_db from .. import models @@ -10,8 +10,8 @@ router = APIRouter() class UserCreate(BaseModel): - username: str - password: str + username: str = Field(min_length=3, max_length=50) + password: str = Field(min_length=8) class Token(BaseModel): diff --git a/btc-portfolio/backend/app/services/btc.py b/btc-portfolio/backend/app/services/btc.py index 20d50f0..02f471c 100644 --- a/btc-portfolio/backend/app/services/btc.py +++ b/btc-portfolio/backend/app/services/btc.py @@ -1,6 +1,9 @@ +import logging import requests from datetime import datetime, timezone +logger = logging.getLogger(__name__) + def get_btc_history_eur() -> list: try: @@ -11,7 +14,8 @@ def get_btc_history_eur() -> list: ) resp.raise_for_status() return resp.json().get("prices", []) # [[timestamp_ms, price], ...] - except Exception: + except Exception as e: + logger.error(f"Failed to fetch BTC history: {e}") return [] @@ -25,7 +29,8 @@ def get_btc_ohlc_eur(days: int) -> list: ) resp.raise_for_status() return resp.json() # [[timestamp_ms, open, high, low, close], ...] - except Exception: + except Exception as e: + logger.error(f"Failed to fetch BTC OHLC: {e}") return [] @@ -58,5 +63,6 @@ def get_btc_price_eur() -> float: ) resp.raise_for_status() return float(resp.json()["bitcoin"]["eur"]) - except Exception: + except Exception as e: + logger.error(f"Failed to fetch BTC price: {e}") return 0.0 diff --git a/btc-portfolio/backend/requirements.txt b/btc-portfolio/backend/requirements.txt index 9c39138..c2f949b 100644 --- a/btc-portfolio/backend/requirements.txt +++ b/btc-portfolio/backend/requirements.txt @@ -2,7 +2,7 @@ fastapi uvicorn[standard] sqlalchemy passlib[bcrypt] -bcrypt==3.2.2 +bcrypt>=4.0.0 python-jose[cryptography] requests python-multipart diff --git a/btc-portfolio/docker-compose.yml b/btc-portfolio/docker-compose.yml index d126cfc..1308903 100644 --- a/btc-portfolio/docker-compose.yml +++ b/btc-portfolio/docker-compose.yml @@ -7,7 +7,14 @@ services: - ./data:/app/data environment: - DATABASE_URL=sqlite:////app/data/btc_portfolio.db + - SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me} restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s frontend: build: @@ -19,3 +26,9 @@ services: depends_on: - backend restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s diff --git a/btc-portfolio/frontend/.dockerignore b/btc-portfolio/frontend/.dockerignore new file mode 100644 index 0000000..ae12c86 --- /dev/null +++ b/btc-portfolio/frontend/.dockerignore @@ -0,0 +1,6 @@ +.git +node_modules +build +.env +.env.local +npm-debug.log diff --git a/btc-portfolio/frontend/Dockerfile b/btc-portfolio/frontend/Dockerfile index 42fb6de..7f73f59 100644 --- a/btc-portfolio/frontend/Dockerfile +++ b/btc-portfolio/frontend/Dockerfile @@ -10,7 +10,10 @@ RUN npm run build FROM node:18-alpine RUN npm install -g serve +RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app COPY --from=build /app/build ./build +RUN chown -R appuser:appgroup /app +USER appuser EXPOSE 3001 CMD ["serve", "-s", "build", "-l", "3001"] diff --git a/btc-portfolio/frontend/src/components/AddPurchase.js b/btc-portfolio/frontend/src/components/AddPurchase.js index 6b1e04b..a4c1add 100644 --- a/btc-portfolio/frontend/src/components/AddPurchase.js +++ b/btc-portfolio/frontend/src/components/AddPurchase.js @@ -39,7 +39,8 @@ export default function AddPurchase({ onAdded }) { setAmountEur(''); setPriceEur(''); onAdded(); - } catch { + } catch (err) { + console.error('AddPurchase network error:', err); setError('Network error'); } }; diff --git a/btc-portfolio/frontend/src/pages/AdminPage.js b/btc-portfolio/frontend/src/pages/AdminPage.js index 810dab0..0152842 100644 --- a/btc-portfolio/frontend/src/pages/AdminPage.js +++ b/btc-portfolio/frontend/src/pages/AdminPage.js @@ -66,7 +66,12 @@ export default function AdminPage() { const handleDelete = async (id, name) => { if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return; - await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() }); + const res = await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.detail || 'Failed to delete user'); + return; + } fetchUsers(); }; diff --git a/btc-portfolio/frontend/src/pages/Login.js b/btc-portfolio/frontend/src/pages/Login.js index 984e60f..872e030 100644 --- a/btc-portfolio/frontend/src/pages/Login.js +++ b/btc-portfolio/frontend/src/pages/Login.js @@ -37,7 +37,8 @@ export default function Login() { localStorage.setItem('token', data.access_token); localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false'); navigate('/'); - } catch { + } catch (err) { + console.error('Login network error:', err); setError('Network error'); } }; diff --git a/btc-portfolio/frontend/src/pages/Register.js b/btc-portfolio/frontend/src/pages/Register.js index 803ff07..2fbccde 100644 --- a/btc-portfolio/frontend/src/pages/Register.js +++ b/btc-portfolio/frontend/src/pages/Register.js @@ -38,7 +38,8 @@ export default function Register() { } setSuccess('Account created! Redirecting...'); setTimeout(() => navigate('/login'), 1500); - } catch { + } catch (err) { + console.error('Register network error:', err); setError('Network error'); } };