security: path traversal fix, bcrypt passwords, OPTIONS preflight skip
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s

This commit is contained in:
markov 2026-02-25 12:21:29 +01:00
parent 9bf66a88b2
commit 2e36b26b05
5 changed files with 45 additions and 14 deletions

View File

@ -7,3 +7,4 @@ pydantic-settings>=2.7
PyJWT>=2.8
python-multipart>=0.0.9
websockets>=14.0
bcrypt>=4.0

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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):