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:
beabigegg
2026-01-11 18:41:19 +08:00
parent 2cb591ef23
commit 679b89ae4c
41 changed files with 3673 additions and 153 deletions

View File

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