Security hardening: go-live review fixes

- TV tokens upgraded from 4 to 6 digits; Regen Token button in Admin
- Nginx rate limiting on TV dashboard and WebSocket endpoints
- Login lockout after 5 failed attempts (15 min); clears on admin password reset
- HSTS header added; CSP unsafe-inline removed from script-src; CORS restricted to explicit methods/headers
- Dependency CVE fixes: PyJWT 2.12.0, aiomysql 0.3.0, cryptography 46.0.5, python-multipart 0.0.22
- datetime.utcnow() replaced with datetime.now(timezone.utc) throughout
- SQL identifier whitelist for startup migration queries
- README updated: security notes section, lockout docs, token regen, NPM proxy guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:00:14 -07:00
parent be86cae7fa
commit 3022bc328b
11 changed files with 228 additions and 30 deletions

View File

@@ -1,3 +1,4 @@
import logging
import random
from contextlib import asynccontextmanager
@@ -19,9 +20,36 @@ from app.websocket.manager import manager
settings = get_settings()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("homeschool")
_ALLOWED_IDENTIFIERS: set[str] = {
# tables
"schedule_blocks", "children", "users", "subjects", "daily_sessions", "timer_events",
# columns
"duration_minutes", "break_time_enabled", "break_time_minutes", "strikes",
"timezone", "is_system", "last_active_at", "strikes_last_reset", "tv_token",
"failed_login_attempts", "locked_until",
# index names
"ix_daily_sessions_session_date", "ix_daily_sessions_is_active", "ix_timer_events_event_type",
# index columns
"session_date", "is_active", "event_type",
}
def _check_identifier(*names: str) -> None:
for name in names:
if name not in _ALLOWED_IDENTIFIERS:
raise ValueError(f"Disallowed SQL identifier: {name!r}")
async def _add_column_if_missing(conn, table: str, column: str, definition: str):
"""Add a column to a table, silently ignoring if it already exists (MySQL 1060)."""
_check_identifier(table, column)
try:
await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}"))
except OperationalError as e:
@@ -29,6 +57,16 @@ async def _add_column_if_missing(conn, table: str, column: str, definition: str)
raise
async def _add_index_if_missing(conn, index_name: str, table: str, column: str):
"""Create an index, silently ignoring if it already exists (MySQL 1061)."""
_check_identifier(index_name, table, column)
try:
await conn.execute(text(f"CREATE INDEX {index_name} ON {table} ({column})"))
except OperationalError as e:
if e.orig.args[0] != 1061: # 1061 = Duplicate key name
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables on startup (Alembic handles migrations in prod, this is a safety net)
@@ -44,6 +82,12 @@ async def lifespan(app: FastAPI):
await _add_column_if_missing(conn, "users", "last_active_at", "DATETIME NULL")
await _add_column_if_missing(conn, "children", "strikes_last_reset", "DATE NULL")
await _add_column_if_missing(conn, "children", "tv_token", "INT NULL")
await _add_column_if_missing(conn, "users", "failed_login_attempts", "INT NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "users", "locked_until", "DATETIME NULL")
# Idempotent index additions
await _add_index_if_missing(conn, "ix_daily_sessions_session_date", "daily_sessions", "session_date")
await _add_index_if_missing(conn, "ix_daily_sessions_is_active", "daily_sessions", "is_active")
await _add_index_if_missing(conn, "ix_timer_events_event_type", "timer_events", "event_type")
# Backfill tv_token for existing children that don't have one
from app.database import AsyncSessionLocal
@@ -53,7 +97,7 @@ async def lifespan(app: FastAPI):
used_tokens = set()
for child in children_without_token:
while True:
token = random.randint(1000, 9999)
token = random.randint(100000, 999999)
if token not in used_tokens:
existing = await db.execute(select(Child).where(Child.tv_token == token))
if not existing.scalar_one_or_none():
@@ -87,9 +131,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Homeschool API",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
docs_url="/api/docs" if settings.docs_enabled else None,
redoc_url="/api/redoc" if settings.docs_enabled else None,
openapi_url="/api/openapi.json" if settings.docs_enabled else None,
lifespan=lifespan,
)
@@ -97,8 +141,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
)
# Routers