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 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 18:40:41 +01:00
parent a0692501b3
commit 85455f3271
16 changed files with 70 additions and 20 deletions
+2
View File
@@ -0,0 +1,2 @@
# Local wallet scripts with credentials — never commit
btc_wallet.py
+7
View File
@@ -0,0 +1,7 @@
.git
__pycache__
*.pyc
*.pyo
.env
*.egg-info
.pytest_cache
+5 -1
View File
@@ -5,8 +5,12 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
COPY . . 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"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+2 -1
View File
@@ -1,8 +1,9 @@
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext 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" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
+2 -2
View File
@@ -13,8 +13,8 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:3001"], allow_origins=["http://localhost:3000", "http://localhost:3001"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Content-Type", "Authorization"],
) )
app.include_router(users.router) app.include_router(users.router)
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import List from typing import List
from datetime import datetime from datetime import datetime
@@ -12,13 +12,13 @@ router = APIRouter()
class PurchaseCreate(BaseModel): class PurchaseCreate(BaseModel):
amount_eur: float amount_eur: float = Field(gt=0, le=10_000_000)
price_eur: float price_eur: float = Field(gt=0, le=10_000_000)
class PurchaseUpdate(BaseModel): class PurchaseUpdate(BaseModel):
amount_eur: float amount_eur: float = Field(gt=0, le=10_000_000)
price_eur: float price_eur: float = Field(gt=0, le=10_000_000)
created_at: datetime created_at: datetime
+3 -3
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel, Field
from ..database import get_db from ..database import get_db
from .. import models from .. import models
@@ -10,8 +10,8 @@ router = APIRouter()
class UserCreate(BaseModel): class UserCreate(BaseModel):
username: str username: str = Field(min_length=3, max_length=50)
password: str password: str = Field(min_length=8)
class Token(BaseModel): class Token(BaseModel):
+9 -3
View File
@@ -1,6 +1,9 @@
import logging
import requests import requests
from datetime import datetime, timezone from datetime import datetime, timezone
logger = logging.getLogger(__name__)
def get_btc_history_eur() -> list: def get_btc_history_eur() -> list:
try: try:
@@ -11,7 +14,8 @@ def get_btc_history_eur() -> list:
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json().get("prices", []) # [[timestamp_ms, price], ...] return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
except Exception: except Exception as e:
logger.error(f"Failed to fetch BTC history: {e}")
return [] return []
@@ -25,7 +29,8 @@ def get_btc_ohlc_eur(days: int) -> list:
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() # [[timestamp_ms, open, high, low, close], ...] 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 [] return []
@@ -58,5 +63,6 @@ def get_btc_price_eur() -> float:
) )
resp.raise_for_status() resp.raise_for_status()
return float(resp.json()["bitcoin"]["eur"]) return float(resp.json()["bitcoin"]["eur"])
except Exception: except Exception as e:
logger.error(f"Failed to fetch BTC price: {e}")
return 0.0 return 0.0
+1 -1
View File
@@ -2,7 +2,7 @@ fastapi
uvicorn[standard] uvicorn[standard]
sqlalchemy sqlalchemy
passlib[bcrypt] passlib[bcrypt]
bcrypt==3.2.2 bcrypt>=4.0.0
python-jose[cryptography] python-jose[cryptography]
requests requests
python-multipart python-multipart
+13
View File
@@ -7,7 +7,14 @@ services:
- ./data:/app/data - ./data:/app/data
environment: environment:
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db - DATABASE_URL=sqlite:////app/data/btc_portfolio.db
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
restart: unless-stopped 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: frontend:
build: build:
@@ -19,3 +26,9 @@ services:
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
+6
View File
@@ -0,0 +1,6 @@
.git
node_modules
build
.env
.env.local
npm-debug.log
+3
View File
@@ -10,7 +10,10 @@ RUN npm run build
FROM node:18-alpine FROM node:18-alpine
RUN npm install -g serve RUN npm install -g serve
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app WORKDIR /app
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3001 EXPOSE 3001
CMD ["serve", "-s", "build", "-l", "3001"] CMD ["serve", "-s", "build", "-l", "3001"]
@@ -39,7 +39,8 @@ export default function AddPurchase({ onAdded }) {
setAmountEur(''); setAmountEur('');
setPriceEur(''); setPriceEur('');
onAdded(); onAdded();
} catch { } catch (err) {
console.error('AddPurchase network error:', err);
setError('Network error'); setError('Network error');
} }
}; };
@@ -66,7 +66,12 @@ export default function AdminPage() {
const handleDelete = async (id, name) => { const handleDelete = async (id, name) => {
if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return; 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(); fetchUsers();
}; };
+2 -1
View File
@@ -37,7 +37,8 @@ export default function Login() {
localStorage.setItem('token', data.access_token); localStorage.setItem('token', data.access_token);
localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false'); localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false');
navigate('/'); navigate('/');
} catch { } catch (err) {
console.error('Login network error:', err);
setError('Network error'); setError('Network error');
} }
}; };
+2 -1
View File
@@ -38,7 +38,8 @@ export default function Register() {
} }
setSuccess('Account created! Redirecting...'); setSuccess('Account created! Redirecting...');
setTimeout(() => navigate('/login'), 1500); setTimeout(() => navigate('/login'), 1500);
} catch { } catch (err) {
console.error('Register network error:', err);
setError('Network error'); setError('Network error');
} }
}; };