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
|
PyJWT>=2.8
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
websockets>=14.0
|
websockets>=14.0
|
||||||
|
bcrypt>=4.0
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user