feat: implement security, error resilience, and query optimization proposals
Security Validation (enhance-security-validation): - JWT secret validation with entropy checking and pattern detection - CSRF protection middleware with token generation/validation - Frontend CSRF token auto-injection for DELETE/PUT/PATCH requests - MIME type validation with magic bytes detection for file uploads Error Resilience (add-error-resilience): - React ErrorBoundary component with fallback UI and retry functionality - ErrorBoundaryWithI18n wrapper for internationalization support - Page-level and section-level error boundaries in App.tsx Query Performance (optimize-query-performance): - Query monitoring utility with threshold warnings - N+1 query fixes using joinedload/selectinload - Optimized project members, tasks, and subtasks endpoints Bug Fixes: - WebSocket session management (P0): Return primitives instead of ORM objects - LIKE query injection (P1): Escape special characters in search queries Tests: 543 backend tests, 56 frontend tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,29 +27,37 @@ if os.getenv("TESTING") == "true":
|
||||
AUTH_TIMEOUT = 1.0
|
||||
|
||||
|
||||
async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
"""Validate token and return user_id and user object."""
|
||||
async def get_user_from_token(token: str) -> str | None:
|
||||
"""
|
||||
Validate token and return user_id.
|
||||
|
||||
Returns:
|
||||
user_id if valid, None otherwise.
|
||||
|
||||
Note: This function properly closes the database session after validation.
|
||||
Do not return ORM objects as they become detached after session close.
|
||||
"""
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
return None, None
|
||||
return None
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None, None
|
||||
return None
|
||||
|
||||
# Verify session in Redis
|
||||
redis_client = get_redis_sync()
|
||||
stored_token = redis_client.get(f"session:{user_id}")
|
||||
if stored_token is None or stored_token != token:
|
||||
return None, None
|
||||
return None
|
||||
|
||||
# Get user from database
|
||||
# Verify user exists and is active
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None or not user.is_active:
|
||||
return None, None
|
||||
return user_id, user
|
||||
return None
|
||||
return user_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -57,7 +65,7 @@ async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
async def authenticate_websocket(
|
||||
websocket: WebSocket,
|
||||
query_token: Optional[str] = None
|
||||
) -> tuple[str | None, User | None, Optional[str]]:
|
||||
) -> tuple[str | None, Optional[str]]:
|
||||
"""
|
||||
Authenticate WebSocket connection.
|
||||
|
||||
@@ -67,7 +75,8 @@ async def authenticate_websocket(
|
||||
2. Query parameter authentication (deprecated, for backward compatibility)
|
||||
- Client connects with: ?token=<jwt_token>
|
||||
|
||||
Returns (user_id, user) if authenticated, (None, None) otherwise.
|
||||
Returns:
|
||||
Tuple of (user_id, error_reason). user_id is None if authentication fails.
|
||||
"""
|
||||
# If token provided via query parameter (backward compatibility)
|
||||
if query_token:
|
||||
@@ -75,10 +84,10 @@ async def authenticate_websocket(
|
||||
"WebSocket authentication via query parameter is deprecated. "
|
||||
"Please use first-message authentication for better security."
|
||||
)
|
||||
user_id, user = await get_user_from_token(query_token)
|
||||
user_id = await get_user_from_token(query_token)
|
||||
if user_id is None:
|
||||
return None, None, "invalid_token"
|
||||
return user_id, user, None
|
||||
return None, "invalid_token"
|
||||
return user_id, None
|
||||
|
||||
# Wait for authentication message with timeout
|
||||
try:
|
||||
@@ -90,24 +99,24 @@ async def authenticate_websocket(
|
||||
msg_type = data.get("type")
|
||||
if msg_type != "auth":
|
||||
logger.warning("Expected 'auth' message type, got: %s", msg_type)
|
||||
return None, None, "invalid_message"
|
||||
return None, "invalid_message"
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
logger.warning("No token provided in auth message")
|
||||
return None, None, "missing_token"
|
||||
return None, "missing_token"
|
||||
|
||||
user_id, user = await get_user_from_token(token)
|
||||
user_id = await get_user_from_token(token)
|
||||
if user_id is None:
|
||||
return None, None, "invalid_token"
|
||||
return user_id, user, None
|
||||
return None, "invalid_token"
|
||||
return user_id, None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("WebSocket authentication timeout after %.1f seconds", AUTH_TIMEOUT)
|
||||
return None, None, "timeout"
|
||||
return None, "timeout"
|
||||
except Exception as e:
|
||||
logger.error("Error during WebSocket authentication: %s", e)
|
||||
return None, None, "error"
|
||||
return None, "error"
|
||||
|
||||
|
||||
async def get_unread_notifications(user_id: str) -> list[dict]:
|
||||
@@ -183,7 +192,7 @@ async def websocket_notifications(
|
||||
await websocket.accept()
|
||||
|
||||
# Authenticate
|
||||
user_id, user, error_reason = await authenticate_websocket(websocket, token)
|
||||
user_id, error_reason = await authenticate_websocket(websocket, token)
|
||||
|
||||
if user_id is None:
|
||||
if error_reason == "invalid_token":
|
||||
@@ -306,7 +315,7 @@ async def websocket_notifications(
|
||||
await manager.disconnect(websocket, user_id)
|
||||
|
||||
|
||||
async def verify_project_access(user_id: str, project_id: str) -> tuple[bool, Project | None]:
|
||||
async def verify_project_access(user_id: str, project_id: str) -> tuple[bool, str | None, str | None]:
|
||||
"""
|
||||
Check if user has access to the project.
|
||||
|
||||
@@ -315,23 +324,34 @@ async def verify_project_access(user_id: str, project_id: str) -> tuple[bool, Pr
|
||||
project_id: The project's ID
|
||||
|
||||
Returns:
|
||||
Tuple of (has_access: bool, project: Project | None)
|
||||
Tuple of (has_access: bool, project_title: str | None, error: str | None)
|
||||
- has_access: True if user can access the project
|
||||
- project_title: The project title (only if access granted)
|
||||
- error: Error code if access denied ("user_not_found", "project_not_found", "access_denied")
|
||||
|
||||
Note: This function extracts needed data before closing the session to avoid
|
||||
detached instance errors when accessing ORM object attributes.
|
||||
"""
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
# Get the user
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None or not user.is_active:
|
||||
return False, None
|
||||
return False, None, "user_not_found"
|
||||
|
||||
# Get the project
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if project is None:
|
||||
return False, None
|
||||
return False, None, "project_not_found"
|
||||
|
||||
# Check access using existing middleware function
|
||||
has_access = check_project_access(user, project)
|
||||
return has_access, project
|
||||
if not has_access:
|
||||
return False, None, "access_denied"
|
||||
|
||||
# Extract title while session is still open
|
||||
project_title = project.title
|
||||
return True, project_title, None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -371,7 +391,7 @@ async def websocket_project_sync(
|
||||
await websocket.accept()
|
||||
|
||||
# Authenticate user
|
||||
user_id, user, error_reason = await authenticate_websocket(websocket, token)
|
||||
user_id, error_reason = await authenticate_websocket(websocket, token)
|
||||
|
||||
if user_id is None:
|
||||
if error_reason == "invalid_token":
|
||||
@@ -380,14 +400,13 @@ async def websocket_project_sync(
|
||||
return
|
||||
|
||||
# Verify user has access to the project
|
||||
has_access, project = await verify_project_access(user_id, project_id)
|
||||
has_access, project_title, access_error = await verify_project_access(user_id, project_id)
|
||||
|
||||
if not has_access:
|
||||
await websocket.close(code=4003, reason="Access denied to this project")
|
||||
return
|
||||
|
||||
if project is None:
|
||||
await websocket.close(code=4004, reason="Project not found")
|
||||
if access_error == "project_not_found":
|
||||
await websocket.close(code=4004, reason="Project not found")
|
||||
else:
|
||||
await websocket.close(code=4003, reason="Access denied to this project")
|
||||
return
|
||||
|
||||
# Join project room
|
||||
@@ -413,7 +432,7 @@ async def websocket_project_sync(
|
||||
"data": {
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
"project_title": project.title if project else None,
|
||||
"project_title": project_title,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user