🐐 Several security fixes

This commit is contained in:
2026-07-29 16:30:14 +02:00
parent b42abe5f5e
commit c56498239f
21 changed files with 660 additions and 204 deletions
+67 -16
View File
@@ -1,5 +1,5 @@
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
@@ -15,34 +15,85 @@ REFRESH_TOKEN_EXPIRE_DAYS = 30
pwd_context = CryptContext(schemes=["bcrypt"])
# Verified against when a login names a user that doesn't exist, so the response time
# is the same either way. Without it, "no such user" returns before bcrypt runs and
# the timing difference tells an attacker which usernames are real.
DUMMY_PASSWORD_HASH = pwd_context.hash("not-a-real-password")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# All token timestamps are timezone-aware UTC. They used to be naive datetime.now(),
# which jose serialises as if it were already UTC — with TZ=Europe/Paris that handed
# out tokens living 1-2 hours longer than intended, drifting again at each DST change.
#
# 'iat' is carried so a password reset can invalidate everything issued before it;
# see token_valid_after on the User model. It is written as a float rather than a
# datetime: jose truncates datetimes to whole seconds, and at that resolution a token
# minted just *after* a reset is indistinguishable from one minted just before, so
# logging in immediately after a password change would be rejected. RFC 7519 permits
# a non-integer NumericDate.
def create_access_token(user_id: str) -> str:
expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode({"sub": user_id, "exp": expire, "type": "access"}, SECRET_KEY, algorithm=ALGORITHM)
now = datetime.now(timezone.utc)
return jwt.encode(
{
"sub": user_id,
"iat": now.timestamp(),
"exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
"type": "access",
},
SECRET_KEY, algorithm=ALGORITHM,
)
def create_refresh_token(user_id: str) -> str:
expire = datetime.now() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
return jwt.encode({"sub": user_id, "exp": expire, "type": "refresh"}, SECRET_KEY, algorithm=ALGORITHM)
now = datetime.now(timezone.utc)
return jwt.encode(
{
"sub": user_id,
"iat": now.timestamp(),
"exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
"type": "refresh",
},
SECRET_KEY, algorithm=ALGORITHM,
)
def decode_refresh_token(token: str) -> str | None:
def _decode(token: str, expected_type: str) -> dict | None:
"""Verified payload, or None if the signature, expiry or type is wrong."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "refresh":
return None
return payload.get("sub")
except JWTError:
return None
if payload.get("type") != expected_type:
return None
return payload
def decode_refresh_token_payload(token: str) -> dict | None:
return _decode(token, "refresh")
def decode_access_token_payload(token: str) -> dict | None:
return _decode(token, "access")
def decode_refresh_token(token: str) -> str | None:
payload = _decode(token, "refresh")
return payload.get("sub") if payload else None
def decode_access_token(token: str) -> str | None:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "access":
return None
return payload.get("sub")
except JWTError:
return None
payload = _decode(token, "access")
return payload.get("sub") if payload else None
def token_issued_before(payload: dict, cutoff: datetime | None) -> bool:
"""True if this token predates a revocation cutoff (i.e. should be rejected)."""
if cutoff is None:
return False
iat = payload.get("iat")
# Tokens minted before 'iat' was introduced have no issue time, so they can't be
# proven to postdate the cutoff — treat them as revoked rather than trusted.
if iat is None:
return True
if cutoff.tzinfo is None:
cutoff = cutoff.replace(tzinfo=timezone.utc)
return datetime.fromtimestamp(iat, timezone.utc) < cutoff
+6 -1
View File
@@ -21,4 +21,9 @@ STRIPE_WEBHOOK_SECRET = require("STRIPE_WEBHOOK_SECRET")
# Optional with sensible defaults for local dev
FRONTEND_URL = optional("FRONTEND_URL", "http://localhost:5173")
CORS_ORIGINS = optional("CORS_ORIGINS", "http://localhost:5173").split(",")
WIKIRANK_USER_AGENT = optional("WIKIRANK_USER_AGENT", "WikiTCG/1.0")
WIKIRANK_USER_AGENT = optional("WIKIRANK_USER_AGENT", "WikiTCG/1.0")
# Serves /docs, /redoc and /openapi.json, i.e. the whole API surface. Opt-in on an
# explicit "true" rather than off on a truthy value, so a typo or an unset var in
# a new environment fails closed.
ENABLE_DOCS = optional("ENABLE_DOCS").lower() == "true"
+126 -17
View File
@@ -1,29 +1,90 @@
import asyncio
import time
import uuid
from collections import defaultdict, deque
from datetime import datetime
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from fastapi.security import OAuth2PasswordBearer
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.orm import Session
from core.auth import decode_access_token
from core.auth import decode_access_token, decode_access_token_payload, token_issued_before
from core.database import get_db
from core.models import User as UserModel
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
# Shared rate limiter — registered on app.state in main.py
limiter = Limiter(key_func=get_remote_address)
def escape_like(value: str) -> str:
r"""Neutralise LIKE wildcards in user input used as a search term.
Without this, '%' matches every row and '_' matches any character — so a one-char
search returns the whole table. Use with .ilike(..., escape='\\').
"""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def parse_uuid(value: str, field: str = "id") -> uuid.UUID:
"""Path and body params reach us as arbitrary strings; a bare uuid.UUID() on one
raises ValueError, which surfaces as a 500 and a stack trace instead of a 400."""
try:
return uuid.UUID(value)
except (ValueError, AttributeError, TypeError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid {field}")
def get_client_ip(conn: Request | WebSocket) -> str:
"""The real external client, as seen through Nginx Proxy Manager.
NPM sets X-Forwarded-For to $proxy_add_x_forwarded_for, which *appends* the peer
it saw to whatever the client sent. So the last entry is always written by NPM and
anything a client forges lands to its left — take the rightmost, never the first.
This trusts the header because NPM is the only way in: port 8000 is unpublished and
the backend only shares the `proxy` network with NPM. If a port is ever published,
or another container is put on `proxy`, this becomes forgeable and needs revisiting.
"""
forwarded = conn.headers.get("x-forwarded-for")
if forwarded:
client = forwarded.rpartition(",")[2].strip()
if client:
return client
# No header: a direct connection, so the peer address is the client.
return conn.client.host if conn.client else "unknown"
# Per-user key where we can, real client IP otherwise. Without this every request
# keys on NPM's container address, i.e. one global bucket for the whole internet —
# eleven logins a minute would lock every user out.
def get_user_id_from_request(request: Request) -> str:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
user_id = decode_access_token(auth[7:])
if user_id:
return f"user:{user_id}"
return get_client_ip(request)
# Default key_func for every @limiter.limit that doesn't override it.
limiter = Limiter(key_func=get_user_id_from_request)
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> UserModel:
user_id = decode_access_token(token)
if not user_id:
payload = decode_access_token_payload(token)
if not payload or not payload.get("sub"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
try:
user_uuid = uuid.UUID(payload["sub"])
except ValueError:
# Signature-verified, so not reachable without the key, but a 500 would be wrong.
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(UserModel).filter(UserModel.id == user_uuid).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
# Single chokepoint for revocation: the user row is already loaded, so it's free.
if token_issued_before(payload, user.token_valid_after):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired, please log in again")
# Throttle to one write per 5 minutes so every authenticated request doesn't hammer the DB
now = datetime.now()
if not user.last_active_at or (now - user.last_active_at).total_seconds() > 300:
@@ -32,12 +93,60 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
return user
# Per-user key for rate limiting authenticated endpoints — prevents shared IPs (NAT/VPN)
# from having their limits pooled. Falls back to remote IP for unauthenticated requests.
def get_user_id_from_request(request: Request) -> str:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
user_id = decode_access_token(auth[7:])
if user_id:
return f"user:{user_id}"
return get_remote_address(request)
## WebSocket connection limiting
# slowapi decorates HTTP routes only, and WS handlers can't authenticate before
# accept() anyway, so connection attempts are limited by client IP instead.
WS_MAX_CONNECTIONS = 30
WS_WINDOW_SECONDS = 60
WS_AUTH_TIMEOUT_SECONDS = 10
_ws_attempts: dict[str, deque[float]] = defaultdict(deque)
async def check_ws_rate_limit(websocket: WebSocket) -> bool:
"""Reject and close if this client is opening sockets too fast.
Call before accept(). Closing an un-accepted WebSocket sends an HTTP rejection,
so a flooding client never gets a live connection or a DB session.
"""
key = get_client_ip(websocket)
now = time.monotonic()
attempts = _ws_attempts[key]
while attempts and now - attempts[0] > WS_WINDOW_SECONDS:
attempts.popleft()
if len(attempts) >= WS_MAX_CONNECTIONS:
await websocket.close(code=1008)
return False
attempts.append(now)
# Keys are unbounded otherwise: drop idle ones whenever the table gets large.
if len(_ws_attempts) > 10000:
for k in [k for k, v in _ws_attempts.items() if not v or now - v[-1] > WS_WINDOW_SECONDS]:
del _ws_attempts[k]
return True
async def accept_and_authenticate_ws(websocket: WebSocket) -> str | None:
"""Rate-limit, accept, then take the auth token from the first frame.
Returns the user id, or None having already closed the socket.
The token can't be read before accept() — ASGI delivers no frames until then — so
the flood limit above is what guards the pre-auth window, and the receive timeout
stops an accepted-but-never-authenticated socket from pinning a DB session.
"""
if not await check_ws_rate_limit(websocket):
return None
await websocket.accept()
try:
token = await asyncio.wait_for(websocket.receive_text(), timeout=WS_AUTH_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
await websocket.close(code=1008)
return None
except WebSocketDisconnect:
return None
user_id = decode_access_token(token)
if not user_id:
await websocket.close(code=1008)
return None
return user_id
+5
View File
@@ -28,6 +28,11 @@ class User(Base):
email_verification_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
trade_wishlist: Mapped[str | None] = mapped_column(Text, nullable=True, default="")
last_active_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Tokens issued before this are rejected — the only way to revoke a session, since
# tokens are pure JWTs with no server-side store. Set on both password-change paths.
# timezone=True unlike its neighbours: it is compared against JWT 'iat', which is
# always UTC, so leaving it naive would make it ambiguous against local-time columns.
token_valid_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cards: Mapped[list["Card"]] = relationship(back_populates="user", cascade="all, delete-orphan")
decks: Mapped[list["Deck"]] = relationship(back_populates="user", cascade="all, delete-orphan")