diff --git a/backend/main.py b/backend/main.py index ea7ebc4..487a0b6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,6 @@ import os import logging +import sys from contextlib import asynccontextmanager from fastapi import FastAPI @@ -12,8 +13,21 @@ from auth import hash_password from routers import eggs, flock, feed, stats, other from routers import auth_router, admin +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stdout, +) logger = logging.getLogger("yolkbook") +_REQUIRED_ENV = ["ADMIN_USERNAME", "ADMIN_PASSWORD", "JWT_SECRET", "DATABASE_URL"] + +def _validate_env(): + missing = [k for k in _REQUIRED_ENV if not os.environ.get(k)] + if missing: + logger.critical("Missing required environment variables: %s", ", ".join(missing)) + sys.exit(1) + def _seed_admin(): """Create or update the admin user from environment variables. @@ -68,6 +82,7 @@ def _run_migrations(): @asynccontextmanager async def lifespan(app: FastAPI): + _validate_env() Base.metadata.create_all(bind=engine) _run_migrations() _seed_admin() diff --git a/backend/routers/admin.py b/backend/routers/admin.py index c945077..f7d336e 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session @@ -8,6 +10,7 @@ from schemas import UserCreate, UserOut, ResetPasswordRequest, TokenResponse from auth import hash_password, create_access_token, get_current_admin, get_current_user router = APIRouter(prefix="/api/admin", tags=["admin"]) +logger = logging.getLogger("yolkbook") @router.get("/users", response_model=list[UserOut]) @@ -50,6 +53,7 @@ def reset_password( raise HTTPException(status_code=404, detail="User not found") user.hashed_password = hash_password(body.new_password) db.commit() + logger.warning("Admin '%s' reset password for user '%s' (id=%d).", current_admin.username, user.username, user.id) return {"detail": f"Password reset for {user.username}"} @@ -66,6 +70,7 @@ def disable_user( raise HTTPException(status_code=400, detail="Cannot disable your own account") user.is_disabled = True db.commit() + logger.warning("Admin '%s' disabled user '%s' (id=%d).", current_admin.username, user.username, user.id) return {"detail": f"User {user.username} disabled"} @@ -94,6 +99,7 @@ def delete_user( raise HTTPException(status_code=404, detail="User not found") if user.id == current_admin.id: raise HTTPException(status_code=400, detail="Cannot delete your own account") + logger.warning("Admin '%s' deleted user '%s' (id=%d).", current_admin.username, user.username, user.id) db.delete(user) db.commit() @@ -101,11 +107,12 @@ def delete_user( @router.post("/users/{user_id}/impersonate", response_model=TokenResponse) def impersonate_user( user_id: int, - _: User = Depends(get_current_admin), + current_admin: User = Depends(get_current_admin), db: Session = Depends(get_db), ): user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") token = create_access_token(user.id, user.username, user.is_admin, user.timezone) + logger.warning("Admin '%s' (id=%d) is impersonating user '%s' (id=%d).", current_admin.username, current_admin.id, user.username, user.id) return TokenResponse(access_token=token) diff --git a/backend/schemas.py b/backend/schemas.py index 193b4a5..f318672 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -16,10 +16,10 @@ class TokenResponse(BaseModel): class ChangePasswordRequest(BaseModel): current_password: str - new_password: str = Field(min_length=6) + new_password: str = Field(min_length=10) class ResetPasswordRequest(BaseModel): - new_password: str = Field(min_length=6) + new_password: str = Field(min_length=10) class TimezoneUpdate(BaseModel): timezone: str = Field(min_length=1, max_length=64) @@ -29,7 +29,7 @@ class TimezoneUpdate(BaseModel): class UserCreate(BaseModel): username: str = Field(min_length=2, max_length=64) - password: str = Field(min_length=6) + password: str = Field(min_length=10) class UserOut(BaseModel): id: int diff --git a/docker-compose.yml b/docker-compose.yml index e918891..bd5e5b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,11 @@ services: image: mysql:8.0 restart: unless-stopped env_file: .env + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE} @@ -27,6 +32,11 @@ services: build: ./backend restart: unless-stopped env_file: .env + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" environment: DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE} ADMIN_USERNAME: ${ADMIN_USERNAME} @@ -42,6 +52,11 @@ services: nginx: image: nginx:alpine restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" ports: - "8056:80" volumes: diff --git a/nginx/html/js/api.js b/nginx/html/js/api.js index 7fffef9..9c09125 100644 --- a/nginx/html/js/api.js +++ b/nginx/html/js/api.js @@ -66,6 +66,17 @@ function fmtMoneyFull(val) { return '$' + Number(val).toFixed(4); } +// Escape HTML special characters to prevent XSS when rendering user content +function escHtml(str) { + if (!str) return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + // Highlight the nav link that matches the current page function highlightNav() { const path = window.location.pathname.replace(/\.html$/, '').replace(/\/$/, '') || '/'; diff --git a/nginx/html/js/budget.js b/nginx/html/js/budget.js index 4ceb039..2b05f5a 100644 --- a/nginx/html/js/budget.js +++ b/nginx/html/js/budget.js @@ -59,7 +59,7 @@ function renderTable() { Feed ${parseFloat(e.bags)} bags @ ${fmtMoney(e.price_per_bag)}/bag ${fmtMoney(total)} - ${e.notes || ''} + ${escHtml(e.notes)} @@ -73,7 +73,7 @@ function renderTable() { Other — ${fmtMoney(e.total)} - ${e.notes || ''} + ${escHtml(e.notes)} @@ -111,7 +111,7 @@ function startEditFeed(id) { /bag — - + @@ -165,7 +165,7 @@ function startEditOther(id) { Other — - + diff --git a/nginx/html/js/dashboard.js b/nginx/html/js/dashboard.js index aabb7b3..1a0ee4c 100644 --- a/nginx/html/js/dashboard.js +++ b/nginx/html/js/dashboard.js @@ -115,7 +115,7 @@ async function loadDashboard() { ${fmtDate(e.date)} ${e.eggs} - ${e.notes || ''} + ${escHtml(e.notes)} `).join(''); diff --git a/nginx/html/js/flock.js b/nginx/html/js/flock.js index ffc7165..6d7efc6 100644 --- a/nginx/html/js/flock.js +++ b/nginx/html/js/flock.js @@ -30,7 +30,7 @@ function renderTable() { ${fmtDate(e.date)} ${e.chicken_count} - ${e.notes || ''} + ${escHtml(e.notes)} @@ -46,7 +46,7 @@ function startEdit(id) { row.innerHTML = ` - + diff --git a/nginx/html/js/history.js b/nginx/html/js/history.js index eef8030..448010c 100644 --- a/nginx/html/js/history.js +++ b/nginx/html/js/history.js @@ -42,7 +42,7 @@ function renderTable() { ${fmtDate(e.date)} ${e.eggs} - ${e.notes || ''} + ${escHtml(e.notes)} @@ -67,7 +67,7 @@ function startEdit(id) { row.innerHTML = ` - + diff --git a/nginx/html/js/log.js b/nginx/html/js/log.js index 3fe8c23..8c4dfbb 100644 --- a/nginx/html/js/log.js +++ b/nginx/html/js/log.js @@ -1,26 +1,3 @@ -async function loadRecent() { - const tbody = document.getElementById('recent-body'); - try { - const eggs = await API.get('/api/eggs'); - const recent = eggs.slice(0, 7); - - if (recent.length === 0) { - tbody.innerHTML = 'No entries yet.'; - return; - } - - tbody.innerHTML = recent.map(e => ` - - ${fmtDate(e.date)} - ${e.eggs} - ${e.notes || ''} - - `).join(''); - } catch (err) { - tbody.innerHTML = 'Could not load recent entries.'; - } -} - document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('log-form'); const msg = document.getElementById('msg'); @@ -42,11 +19,9 @@ document.addEventListener('DOMContentLoaded', () => { showMessage(msg, 'Entry saved!'); form.reset(); setToday(document.getElementById('date')); - loadRecent(); + loadHistory(); } catch (err) { showMessage(msg, `Error: ${err.message}`, 'error'); } }); - - loadRecent(); }); diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 9d6434c..4351a1a 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -13,6 +13,9 @@ http { gzip_types text/plain text/css application/javascript application/json; gzip_min_length 1000; + # ── Rate limiting — login endpoint ──────────────────────────────────────── + limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + server { listen 80; server_name _; @@ -20,6 +23,12 @@ http { root /usr/share/nginx/html; index index.html; + # ── Security headers ────────────────────────────────────────────────── + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # ── Static files ────────────────────────────────────────────────────── location / { try_files $uri $uri.html $uri/ =404; @@ -28,10 +37,33 @@ http { # Cache static assets aggressively and suppress access log noise location ~* \.(css|js|svg|ico|png|jpg|webp|woff2?)$ { expires 7d; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; access_log off; } + # ── Login rate limiting ─────────────────────────────────────────────── + location = /api/auth/login { + limit_req zone=login burst=3 nodelay; + + proxy_pass http://api:8000; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } + # ── API reverse proxy ───────────────────────────────────────────────── # All /api/* requests are forwarded to the FastAPI container. # The container is reachable by its service name on the Docker network. @@ -44,8 +76,11 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # Don't cache API responses - add_header Cache-Control "no-store"; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; } # ── Custom error pages ────────────────────────────────────────────────