From 2e36b26b0538176c12e239d06a75e6311501f81e Mon Sep 17 00:00:00 2001 From: markov Date: Wed, 25 Feb 2026 12:21:29 +0100 Subject: [PATCH] security: path traversal fix, bcrypt passwords, OPTIONS preflight skip --- requirements.txt | 1 + src/tracker/api/auth.py | 20 ++++++++++++++++++-- src/tracker/api/project_files.py | 31 ++++++++++++++++++++++--------- src/tracker/app.py | 3 ++- src/tracker/init_db.py | 4 ++-- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/requirements.txt b/requirements.txt index a60bccf..3f849bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ pydantic-settings>=2.7 PyJWT>=2.8 python-multipart>=0.0.9 websockets>=14.0 +bcrypt>=4.0 diff --git a/src/tracker/api/auth.py b/src/tracker/api/auth.py index d7e2264..affc17e 100644 --- a/src/tracker/api/auth.py +++ b/src/tracker/api/auth.py @@ -3,6 +3,7 @@ import hashlib from datetime import datetime, timedelta, timezone +import bcrypt import jwt from fastapi import APIRouter, Depends, HTTPException, Header from pydantic import BaseModel @@ -20,7 +21,17 @@ JWT_EXPIRE_HOURS = 72 def hash_password(password: str) -> str: - return hashlib.sha256(password.encode()).hexdigest() + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(password: str, hashed: str) -> bool: + """Verify password against hash. Supports bcrypt and legacy SHA-256.""" + if hashed.startswith("$2"): + # bcrypt hash + return bcrypt.checkpw(password.encode(), hashed.encode()) + else: + # Legacy SHA-256 — migrate on next login + return hashlib.sha256(password.encode()).hexdigest() == hashed def create_jwt(member_id: str, slug: str, role: str) -> str: @@ -92,9 +103,14 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): ) ) member = result.scalar_one_or_none() - if not member or member.password_hash != hash_password(req.password): + if not member or not member.password_hash or not verify_password(req.password, member.password_hash): raise HTTPException(401, "Invalid login or password") + # Migrate legacy SHA-256 hash to bcrypt on successful login + if not member.password_hash.startswith("$2"): + member.password_hash = hash_password(req.password) + await db.commit() + token = create_jwt(str(member.id), member.slug, member.role) return LoginResponse( token=token, diff --git a/src/tracker/api/project_files.py b/src/tracker/api/project_files.py index 4dacd4d..24a624c 100644 --- a/src/tracker/api/project_files.py +++ b/src/tracker/api/project_files.py @@ -116,22 +116,31 @@ async def upload_project_file( if not file.filename: raise HTTPException(400, "Filename is required") + # Sanitize filename — prevent path traversal + safe_filename = os.path.basename(file.filename).strip() + if not safe_filename or safe_filename.startswith('.'): + raise HTTPException(400, "Invalid filename") + # Create project directory if not exists project_dir = os.path.join(PROJECTS_DIR, slug) os.makedirs(project_dir, exist_ok=True) + # Verify resolved path is inside project dir + full_path = os.path.realpath(os.path.join(project_dir, safe_filename)) + if not full_path.startswith(os.path.realpath(project_dir) + os.sep): + raise HTTPException(400, "Invalid filename") + # Check if file with same name already exists result = await db.execute( select(ProjectFile).where( ProjectFile.project_id == project.id, - ProjectFile.filename == file.filename + ProjectFile.filename == safe_filename ) ) existing_file = result.scalar_one_or_none() # Save file to disk - storage_path = file.filename # relative path inside project dir - full_path = os.path.join(project_dir, file.filename) + storage_path = safe_filename with open(full_path, "wb") as f: f.write(content) @@ -154,7 +163,7 @@ async def upload_project_file( # Create new file record project_file = ProjectFile( project_id=project.id, - filename=file.filename, + filename=safe_filename, description=description, mime_type=mime_type, size=len(content), @@ -213,8 +222,11 @@ async def download_project_file( if not project_file: raise HTTPException(404, "File not found") - # Full path to file - full_path = os.path.join(PROJECTS_DIR, slug, project_file.storage_path) + # Full path to file — validate it stays inside project dir + project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug)) + full_path = os.path.realpath(os.path.join(project_dir, project_file.storage_path)) + if not full_path.startswith(project_dir + os.sep): + raise HTTPException(400, "Invalid file path") if not os.path.exists(full_path): raise HTTPException(404, "File not found on disk") @@ -281,9 +293,10 @@ async def delete_project_file( if not project_file: raise HTTPException(404, "File not found") - # Delete file from disk - full_path = os.path.join(PROJECTS_DIR, slug, project_file.storage_path) - if os.path.exists(full_path): + # Delete file from disk — validate path + project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug)) + full_path = os.path.realpath(os.path.join(project_dir, project_file.storage_path)) + if full_path.startswith(project_dir + os.sep) and os.path.exists(full_path): os.remove(full_path) # Delete from database diff --git a/src/tracker/app.py b/src/tracker/app.py index f5083a9..ef001a0 100644 --- a/src/tracker/app.py +++ b/src/tracker/app.py @@ -98,7 +98,8 @@ async def auth_middleware(request: Request, call_next): """Verify token for all API requests (except login).""" path = request.url.path - if not path.startswith("/api/") or path in NO_AUTH_API: + # Skip CORS preflight and non-API paths + if request.method == "OPTIONS" or not path.startswith("/api/") or path in NO_AUTH_API: return await call_next(request) # Extract token from header or query param diff --git a/src/tracker/init_db.py b/src/tracker/init_db.py index 70004cc..ceea3db 100644 --- a/src/tracker/init_db.py +++ b/src/tracker/init_db.py @@ -1,7 +1,7 @@ """Initialize database — dev seed data.""" import asyncio -import hashlib +import bcrypt import logging from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +13,7 @@ logger = logging.getLogger("tracker.init_db") def hash_password(password: str) -> str: - return hashlib.sha256(password.encode()).hexdigest() + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() async def seed_dev_data(session: AsyncSession):