security: httpOnly cookies, rate limiting, nginx headers, SSE sanitization

Auth / token storage:
- JWT is now set as an httpOnly Secure SameSite=Lax cookie on login
- Add POST /auth/logout endpoint that clears the cookie
- get_current_member falls back to rh_token cookie when no Authorization header
- WebSocket auth now accepts cookie (rh_token) or optional ?token= query param
- Frontend removes all localStorage JWT access; uses credentials:"include" on
  every fetch so the httpOnly cookie is sent automatically
- Replace clearToken() with logout() that calls the server logout endpoint
- Non-sensitive rh_session flag in localStorage used only for client-side routing

Rate limiting:
- Add slowapi>=0.1.9 dependency
- /auth/login limited to 10 req/min per IP
- /auth/register limited to 5 req/min per IP

Nginx security headers:
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  X-XSS-Protection, Permissions-Policy to all responses

SSE error leakage:
- songs.py nc-scan/stream no longer leaks str(exc) to clients

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-03-30 21:11:53 +02:00
parent 68da26588a
commit c1941ed9ac
14 changed files with 109 additions and 40 deletions

View File

@@ -20,6 +20,7 @@ dependencies = [
"redis[hiredis]>=5.0", "redis[hiredis]>=5.0",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"Pillow>=10.0", "Pillow>=10.0",
"slowapi>=0.1.9",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid import uuid
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,18 +13,25 @@ from rehearsalhub.db.models import Member
from rehearsalhub.services.auth import decode_token from rehearsalhub.services.auth import decode_token
from rehearsalhub.repositories.member import MemberRepository from rehearsalhub.repositories.member import MemberRepository
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") # auto_error=False so we can fall back to cookie auth without a 401 from the scheme itself
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
async def get_current_member( async def get_current_member(
token: str = Depends(oauth2_scheme), request: Request,
bearer_token: str | None = Depends(oauth2_scheme),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Member: ) -> Member:
# Prefer Authorization: Bearer header; fall back to httpOnly cookie
token = bearer_token or request.cookies.get("rh_token")
credentials_exc = HTTPException( credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token", detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not token:
raise credentials_exc
try: try:
payload = decode_token(token) payload = decode_token(token)
member_id_str: str | None = payload.get("sub") member_id_str: str | None = payload.get("sub")

View File

@@ -6,6 +6,9 @@ import os
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from rehearsalhub.config import get_settings from rehearsalhub.config import get_settings
from rehearsalhub.routers import ( from rehearsalhub.routers import (
@@ -20,6 +23,8 @@ from rehearsalhub.routers import (
ws_router, ws_router,
) )
limiter = Limiter(key_func=get_remote_address)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -43,6 +48,9 @@ def create_app() -> FastAPI:
lifespan=lifespan, lifespan=lifespan,
) )
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[f"https://{settings.domain}", "http://localhost:3000"], allow_origins=[f"https://{settings.domain}", "http://localhost:3000"],

View File

@@ -2,10 +2,13 @@ import logging
import os import os
import uuid import uuid
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member from rehearsalhub.dependencies import get_current_member
@@ -17,13 +20,15 @@ from rehearsalhub.services.auth import AuthService
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
limiter = Limiter(key_func=get_remote_address)
_ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} _ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
_MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5 MB _MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5 MB
@router.post("/register", response_model=MemberRead, status_code=status.HTTP_201_CREATED) @router.post("/register", response_model=MemberRead, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, session: AsyncSession = Depends(get_session)): @limiter.limit("5/minute")
async def register(request: Request, req: RegisterRequest, session: AsyncSession = Depends(get_session)):
svc = AuthService(session) svc = AuthService(session)
try: try:
member = await svc.register(req) member = await svc.register(req)
@@ -33,16 +38,38 @@ async def register(req: RegisterRequest, session: AsyncSession = Depends(get_ses
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, session: AsyncSession = Depends(get_session)): @limiter.limit("10/minute")
async def login(
request: Request,
req: LoginRequest,
response: Response,
session: AsyncSession = Depends(get_session),
):
svc = AuthService(session) svc = AuthService(session)
token = await svc.login(req.email, req.password) token = await svc.login(req.email, req.password)
if token is None: if token is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials" status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
) )
settings = get_settings()
response.set_cookie(
key="rh_token",
value=token.access_token,
httponly=True,
secure=not settings.debug,
samesite="lax",
max_age=settings.access_token_expire_minutes * 60,
path="/",
)
return token return token
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(response: Response):
response.delete_cookie(key="rh_token", path="/")
return None
@router.get("/me", response_model=MemberRead) @router.get("/me", response_model=MemberRead)
async def get_me(current_member: Member = Depends(get_current_member)): async def get_me(current_member: Member = Depends(get_current_member)):
return MemberRead.from_model(current_member) return MemberRead.from_model(current_member)

View File

@@ -180,9 +180,9 @@ async def scan_nextcloud_stream(
yield json.dumps(event) + "\n" yield json.dumps(event) + "\n"
if event.get("type") in ("song", "session"): if event.get("type") in ("song", "session"):
await db.commit() await db.commit()
except Exception as exc: except Exception:
log.exception("SSE scan error for band %s", band_id) log.exception("SSE scan error for band %s", band_id)
yield json.dumps({"type": "error", "message": str(exc)}) + "\n" yield json.dumps({"type": "error", "message": "Scan failed due to an internal error."}) + "\n"
finally: finally:
await db.commit() await db.commit()

View File

@@ -16,13 +16,19 @@ router = APIRouter(tags=["websocket"])
async def version_ws( async def version_ws(
version_id: uuid.UUID, version_id: uuid.UUID,
websocket: WebSocket, websocket: WebSocket,
token: str = Query(...), token: str | None = Query(None),
): ):
"""WebSocket endpoint. Requires a valid JWT passed as ?token=<jwt>.""" """
# Validate token before accepting the connection WebSocket endpoint. Authentication via:
- ?token=<jwt> query parameter, or
- rh_token httpOnly cookie (sent automatically by the browser)
"""
raw_token = token or websocket.cookies.get("rh_token")
async for session in get_session(): async for session in get_session():
try: try:
payload = decode_token(token) if not raw_token:
raise ValueError("no token")
payload = decode_token(raw_token)
member_id = uuid.UUID(payload["sub"]) member_id = uuid.UUID(payload["sub"])
member = await MemberRepository(session).get_by_id(member_id) member = await MemberRepository(session).get_by_id(member_id)
if member is None: if member is None:

View File

@@ -3,6 +3,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "0" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Allow avatar uploads up to 10MB (API enforces a 5MB limit) # Allow avatar uploads up to 10MB (API enforces a 5MB limit)
client_max_body_size 10m; client_max_body_size 10m;

View File

@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
import "./index.css"; import "./index.css";
import { ThemeProvider, useTheme } from "./theme"; import { ThemeProvider, useTheme } from "./theme";
import { isLoggedIn } from "./api/client";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { BandPage } from "./pages/BandPage"; import { BandPage } from "./pages/BandPage";
@@ -15,8 +16,7 @@ const queryClient = new QueryClient({
}); });
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem("rh_token"); return isLoggedIn() ? <>{children}</> : <Navigate to="/login" replace />;
return token ? <>{children}</> : <Navigate to="/login" replace />;
} }
function ThemeToggle() { function ThemeToggle() {

View File

@@ -1,4 +1,4 @@
import { api, setToken } from "./client"; import { api, markLoggedIn, markLoggedOut } from "./client";
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
@@ -20,10 +20,19 @@ export interface MemberRead {
export async function login(req: LoginRequest): Promise<TokenResponse> { export async function login(req: LoginRequest): Promise<TokenResponse> {
const resp = await api.post<TokenResponse>("/auth/login", req); const resp = await api.post<TokenResponse>("/auth/login", req);
setToken(resp.access_token); markLoggedIn();
return resp; return resp;
} }
export async function logout(): Promise<void> {
try {
await api.post("/auth/logout", {});
} finally {
markLoggedOut();
window.location.href = "/login";
}
}
export async function register(req: { export async function register(req: {
email: string; email: string;
password: string; password: string;

View File

@@ -1,15 +1,21 @@
const BASE = "/api/v1"; const BASE = "/api/v1";
function getToken(): string | null { // A non-sensitive flag in localStorage that tells the SPA whether the user has
return localStorage.getItem("rh_token"); // an active session. The actual JWT lives in an httpOnly cookie and is never
// readable by JavaScript. Clearing this flag is sufficient for client-side
// route guards; the server still validates the cookie on every request.
const SESSION_KEY = "rh_session";
export function markLoggedIn(): void {
localStorage.setItem(SESSION_KEY, "1");
} }
export function setToken(token: string): void { export function markLoggedOut(): void {
localStorage.setItem("rh_token", token); localStorage.removeItem(SESSION_KEY);
} }
export function clearToken(): void { export function isLoggedIn(): boolean {
localStorage.removeItem("rh_token"); return localStorage.getItem(SESSION_KEY) === "1";
} }
async function request<T>( async function request<T>(
@@ -17,20 +23,20 @@ async function request<T>(
options: RequestInit = {}, options: RequestInit = {},
isFormData = false isFormData = false
): Promise<T> { ): Promise<T> {
const token = getToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
if (!isFormData) { if (!isFormData) {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
} }
if (token) { const resp = await fetch(`${BASE}${path}`, {
headers["Authorization"] = `Bearer ${token}`; ...options,
} headers,
const resp = await fetch(`${BASE}${path}`, { ...options, headers }); credentials: "include", // send httpOnly cookie on every request
});
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401) { if (resp.status === 401) {
clearToken(); markLoggedOut();
window.location.href = "/login"; window.location.href = "/login";
throw new Error("Session expired"); throw new Error("Session expired");
} }

View File

@@ -40,9 +40,8 @@ export function useWaveform(
normalize: true, normalize: true,
}); });
const token = localStorage.getItem("rh_token"); // The rh_token httpOnly cookie is sent automatically by the browser.
const audioUrl = token ? `${options.url}?token=${encodeURIComponent(token)}` : options.url; ws.load(options.url);
ws.load(audioUrl);
ws.on("ready", () => { ws.on("ready", () => {
setIsReady(true); setIsReady(true);

View File

@@ -123,11 +123,11 @@ export function BandPage() {
setScanMsg(null); setScanMsg(null);
setScanProgress("Starting scan…"); setScanProgress("Starting scan…");
const token = localStorage.getItem("rh_token"); const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
const url = `/api/v1/bands/${bandId}/nc-scan/stream${token ? `?token=${encodeURIComponent(token)}` : ""}`;
try { try {
const resp = await fetch(url); // credentials: "include" sends the rh_token httpOnly cookie automatically
const resp = await fetch(url, { credentials: "include" });
if (!resp.ok || !resp.body) { if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => resp.statusText); const text = await resp.text().catch(() => resp.statusText);
throw new Error(text || `HTTP ${resp.status}`); throw new Error(text || `HTTP ${resp.status}`);

View File

@@ -2,7 +2,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { listBands, createBand } from "../api/bands"; import { listBands, createBand } from "../api/bands";
import { clearToken } from "../api/client"; import { logout } from "../api/auth";
export function HomePage() { export function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -29,8 +29,7 @@ export function HomePage() {
}); });
function handleSignOut() { function handleSignOut() {
clearToken(); logout();
navigate("/login");
} }
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { api } from "../api/client"; import { api, isLoggedIn } from "../api/client";
interface InviteInfo { interface InviteInfo {
id: string; id: string;
@@ -19,7 +19,7 @@ export function InvitePage() {
const [accepting, setAccepting] = useState(false); const [accepting, setAccepting] = useState(false);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const isLoggedIn = !!localStorage.getItem("rh_token"); const loggedIn = isLoggedIn();
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
@@ -74,7 +74,7 @@ export function InvitePage() {
{invite.used_at && " · Already used"} {invite.used_at && " · Already used"}
</p> </p>
{isLoggedIn ? ( {loggedIn ? (
<button <button
onClick={accept} onClick={accept}
disabled={accepting || !!invite.used_at} disabled={accepting || !!invite.used_at}