security: path traversal fix, bcrypt passwords, OPTIONS preflight skip
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
This commit is contained in:
parent
9bf66a88b2
commit
2e36b26b05
@ -7,3 +7,4 @@ pydantic-settings>=2.7
|
||||
PyJWT>=2.8
|
||||
python-multipart>=0.0.9
|
||||
websockets>=14.0
|
||||
bcrypt>=4.0
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user