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 PyJWT>=2.8
python-multipart>=0.0.9 python-multipart>=0.0.9
websockets>=14.0 websockets>=14.0
bcrypt>=4.0

View File

@ -3,6 +3,7 @@
import hashlib import hashlib
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import bcrypt
import jwt import jwt
from fastapi import APIRouter, Depends, HTTPException, Header from fastapi import APIRouter, Depends, HTTPException, Header
from pydantic import BaseModel from pydantic import BaseModel
@ -20,7 +21,17 @@ JWT_EXPIRE_HOURS = 72
def hash_password(password: str) -> str: 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: 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() 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") 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) token = create_jwt(str(member.id), member.slug, member.role)
return LoginResponse( return LoginResponse(
token=token, token=token,

View File

@ -116,22 +116,31 @@ async def upload_project_file(
if not file.filename: if not file.filename:
raise HTTPException(400, "Filename is required") 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 # Create project directory if not exists
project_dir = os.path.join(PROJECTS_DIR, slug) project_dir = os.path.join(PROJECTS_DIR, slug)
os.makedirs(project_dir, exist_ok=True) 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 # Check if file with same name already exists
result = await db.execute( result = await db.execute(
select(ProjectFile).where( select(ProjectFile).where(
ProjectFile.project_id == project.id, ProjectFile.project_id == project.id,
ProjectFile.filename == file.filename ProjectFile.filename == safe_filename
) )
) )
existing_file = result.scalar_one_or_none() existing_file = result.scalar_one_or_none()
# Save file to disk # Save file to disk
storage_path = file.filename # relative path inside project dir storage_path = safe_filename
full_path = os.path.join(project_dir, file.filename)
with open(full_path, "wb") as f: with open(full_path, "wb") as f:
f.write(content) f.write(content)
@ -154,7 +163,7 @@ async def upload_project_file(
# Create new file record # Create new file record
project_file = ProjectFile( project_file = ProjectFile(
project_id=project.id, project_id=project.id,
filename=file.filename, filename=safe_filename,
description=description, description=description,
mime_type=mime_type, mime_type=mime_type,
size=len(content), size=len(content),
@ -213,8 +222,11 @@ async def download_project_file(
if not project_file: if not project_file:
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
# Full path to file # Full path to file — validate it stays inside project dir
full_path = os.path.join(PROJECTS_DIR, slug, project_file.storage_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 not full_path.startswith(project_dir + os.sep):
raise HTTPException(400, "Invalid file path")
if not os.path.exists(full_path): if not os.path.exists(full_path):
raise HTTPException(404, "File not found on disk") raise HTTPException(404, "File not found on disk")
@ -281,9 +293,10 @@ async def delete_project_file(
if not project_file: if not project_file:
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
# Delete file from disk # Delete file from disk — validate path
full_path = os.path.join(PROJECTS_DIR, slug, project_file.storage_path) project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug))
if os.path.exists(full_path): 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) os.remove(full_path)
# Delete from database # 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).""" """Verify token for all API requests (except login)."""
path = request.url.path 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) return await call_next(request)
# Extract token from header or query param # Extract token from header or query param

View File

@ -1,7 +1,7 @@
"""Initialize database — dev seed data.""" """Initialize database — dev seed data."""
import asyncio import asyncio
import hashlib import bcrypt
import logging import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -13,7 +13,7 @@ logger = logging.getLogger("tracker.init_db")
def hash_password(password: str) -> str: 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): async def seed_dev_data(session: AsyncSession):