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",
"python-multipart>=0.0.9",
"Pillow>=10.0",
"slowapi>=0.1.9",
]
[project.optional-dependencies]

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
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.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(
token: str = Depends(oauth2_scheme),
request: Request,
bearer_token: str | None = Depends(oauth2_scheme),
session: AsyncSession = Depends(get_session),
) -> Member:
# Prefer Authorization: Bearer header; fall back to httpOnly cookie
token = bearer_token or request.cookies.get("rh_token")
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise credentials_exc
try:
payload = decode_token(token)
member_id_str: str | None = payload.get("sub")

View File

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

View File

@@ -2,10 +2,13 @@ import logging
import os
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 slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
@@ -17,13 +20,15 @@ from rehearsalhub.services.auth import AuthService
log = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
limiter = Limiter(key_func=get_remote_address)
_ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
_MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5 MB
@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)
try:
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)
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)
token = await svc.login(req.email, req.password)
if token is None:
raise HTTPException(
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
@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)
async def get_me(current_member: Member = Depends(get_current_member)):
return MemberRead.from_model(current_member)

View File

@@ -180,9 +180,9 @@ async def scan_nextcloud_stream(
yield json.dumps(event) + "\n"
if event.get("type") in ("song", "session"):
await db.commit()
except Exception as exc:
except Exception:
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:
await db.commit()

View File

@@ -16,13 +16,19 @@ router = APIRouter(tags=["websocket"])
async def version_ws(
version_id: uuid.UUID,
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():
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 = await MemberRepository(session).get_by_id(member_id)
if member is None:

View File

@@ -3,6 +3,13 @@ server {
root /usr/share/nginx/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)
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 "./index.css";
import { ThemeProvider, useTheme } from "./theme";
import { isLoggedIn } from "./api/client";
import { LoginPage } from "./pages/LoginPage";
import { HomePage } from "./pages/HomePage";
import { BandPage } from "./pages/BandPage";
@@ -15,8 +16,7 @@ const queryClient = new QueryClient({
});
function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem("rh_token");
return token ? <>{children}</> : <Navigate to="/login" replace />;
return isLoggedIn() ? <>{children}</> : <Navigate to="/login" replace />;
}
function ThemeToggle() {

View File

@@ -1,4 +1,4 @@
import { api, setToken } from "./client";
import { api, markLoggedIn, markLoggedOut } from "./client";
export interface LoginRequest {
email: string;
@@ -20,10 +20,19 @@ export interface MemberRead {
export async function login(req: LoginRequest): Promise<TokenResponse> {
const resp = await api.post<TokenResponse>("/auth/login", req);
setToken(resp.access_token);
markLoggedIn();
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: {
email: string;
password: string;

View File

@@ -1,15 +1,21 @@
const BASE = "/api/v1";
function getToken(): string | null {
return localStorage.getItem("rh_token");
// A non-sensitive flag in localStorage that tells the SPA whether the user has
// 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 {
localStorage.setItem("rh_token", token);
export function markLoggedOut(): void {
localStorage.removeItem(SESSION_KEY);
}
export function clearToken(): void {
localStorage.removeItem("rh_token");
export function isLoggedIn(): boolean {
return localStorage.getItem(SESSION_KEY) === "1";
}
async function request<T>(
@@ -17,20 +23,20 @@ async function request<T>(
options: RequestInit = {},
isFormData = false
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (!isFormData) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const resp = await fetch(`${BASE}${path}`, { ...options, headers });
const resp = await fetch(`${BASE}${path}`, {
...options,
headers,
credentials: "include", // send httpOnly cookie on every request
});
if (!resp.ok) {
if (resp.status === 401) {
clearToken();
markLoggedOut();
window.location.href = "/login";
throw new Error("Session expired");
}

View File

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

View File

@@ -123,11 +123,11 @@ export function BandPage() {
setScanMsg(null);
setScanProgress("Starting scan…");
const token = localStorage.getItem("rh_token");
const url = `/api/v1/bands/${bandId}/nc-scan/stream${token ? `?token=${encodeURIComponent(token)}` : ""}`;
const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
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) {
const text = await resp.text().catch(() => resp.statusText);
throw new Error(text || `HTTP ${resp.status}`);

View File

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

View File

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