Harden API security: input validation, safe auth extraction, new tests, and deploy config

Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-02 09:48:01 -06:00
parent 56d6fa4514
commit 7690f07a2b
123 changed files with 8321 additions and 750 deletions

View File

@@ -560,11 +560,7 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
Rating: req.Rating,
}
if err := s.taskRepo.CreateCompletion(completion); err != nil {
return nil, apperrors.Internal(err)
}
// Update next_due_date and in_progress based on frequency
// Determine interval days for NextDueDate calculation before entering the transaction.
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil (marks as completed)
// - If frequency is "Custom", use task.CustomIntervalDays for recurrence
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
@@ -598,11 +594,25 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
// instead of staying in "In Progress" column
task.InProgress = false
}
if err := s.taskRepo.Update(task); err != nil {
if errors.Is(err, repositories.ErrVersionConflict) {
// P1-5: Wrap completion creation and task update in a transaction.
// If either operation fails, both are rolled back to prevent orphaned completions.
txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil {
return err
}
if err := s.taskRepo.UpdateTx(tx, task); err != nil {
return err
}
return nil
})
if txErr != nil {
// P1-6: Return the error instead of swallowing it.
if errors.Is(txErr, repositories.ErrVersionConflict) {
return nil, apperrors.Conflict("error.version_conflict")
}
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion")
log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task")
return nil, apperrors.Internal(txErr)
}
// Create images if provided
@@ -731,8 +741,15 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
}
log.Info().Uint("task_id", task.ID).Msg("QuickComplete: Task updated successfully")
// Send notification (fire and forget)
go s.sendTaskCompletedNotification(task, completion)
// Send notification (fire and forget with panic recovery)
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Uint("task_id", task.ID).Msg("Panic in quick-complete notification goroutine")
}
}()
s.sendTaskCompletedNotification(task, completion)
}()
return nil
}
@@ -764,23 +781,23 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
emailImages = s.loadCompletionImagesForEmail(completion.Images)
}
// Notify all users
// Notify all users synchronously to avoid unbounded goroutine spawning.
// This method is already called from a goroutine (QuickComplete) or inline
// (CreateCompletion) where blocking is acceptable for notification delivery.
for _, user := range users {
isCompleter := user.ID == completion.CompletedByID
// Send push notification (to everyone EXCEPT the person who completed it)
if !isCompleter && s.notificationService != nil {
go func(userID uint) {
ctx := context.Background()
if err := s.notificationService.CreateAndSendTaskNotification(
ctx,
userID,
models.NotificationTaskCompleted,
task,
); err != nil {
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
}
}(user.ID)
ctx := context.Background()
if err := s.notificationService.CreateAndSendTaskNotification(
ctx,
user.ID,
models.NotificationTaskCompleted,
task,
); err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
}
}
// Send email notification (to everyone INCLUDING the person who completed it)
@@ -789,20 +806,18 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
prefs, err := s.notificationService.GetPreferences(user.ID)
if err != nil || (prefs != nil && prefs.EmailTaskCompleted) {
// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
go func(u models.User, images []EmbeddedImage) {
if err := s.emailService.SendTaskCompletedEmail(
u.Email,
u.GetFullName(),
task.Title,
completedByName,
residenceName,
images,
); err != nil {
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
} else {
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Int("images", len(images)).Msg("Task completion email sent")
}
}(user, emailImages)
if err := s.emailService.SendTaskCompletedEmail(
user.Email,
user.GetFullName(),
task.Title,
completedByName,
residenceName,
emailImages,
); err != nil {
log.Error().Err(err).Str("email", user.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
} else {
log.Info().Str("email", user.Email).Uint("task_id", task.ID).Int("images", len(emailImages)).Msg("Task completion email sent")
}
}
}
}
@@ -846,20 +861,28 @@ func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletio
return emailImages
}
// resolveImageFilePath converts a stored URL to an actual file path
// resolveImageFilePath converts a stored URL to an actual file path.
// Returns empty string if the URL is empty or the resolved path would escape
// the upload directory (path traversal attempt).
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
if storedURL == "" {
return ""
}
// Handle /uploads/... URLs
// Strip legacy /uploads/ prefix to get relative path
relativePath := storedURL
if strings.HasPrefix(storedURL, "/uploads/") {
relativePath := strings.TrimPrefix(storedURL, "/uploads/")
return filepath.Join(uploadDir, relativePath)
relativePath = strings.TrimPrefix(storedURL, "/uploads/")
}
// Handle relative paths
return filepath.Join(uploadDir, storedURL)
// Use SafeResolvePath to validate containment within upload directory
resolved, err := SafeResolvePath(uploadDir, relativePath)
if err != nil {
// Path traversal or invalid path — return empty to signal file not found
return ""
}
return resolved
}
// getContentTypeFromPath returns the MIME type based on file extension
@@ -977,7 +1000,11 @@ func (s *TaskService) UpdateCompletion(completionID, userID uint, req *requests.
return &resp, nil
}
// DeleteCompletion deletes a task completion
// DeleteCompletion deletes a task completion and recalculates the task's NextDueDate.
//
// P1-7: After deleting a completion, NextDueDate must be recalculated:
// - If no completions remain: restore NextDueDate = DueDate (original schedule)
// - If completions remain (recurring): recalculate from latest remaining completion + frequency days
func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
completion, err := s.taskRepo.FindCompletionByID(completionID)
if err != nil {
@@ -996,10 +1023,66 @@ func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.De
return nil, apperrors.Forbidden("error.task_access_denied")
}
taskID := completion.TaskID
if err := s.taskRepo.DeleteCompletion(completionID); err != nil {
return nil, apperrors.Internal(err)
}
// Recalculate NextDueDate based on remaining completions
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
// Non-fatal for the delete operation itself, but log the error
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to reload task after completion deletion for NextDueDate recalculation")
return &responses.DeleteWithSummaryResponse{
Data: "completion deleted",
Summary: s.getSummaryForUser(userID),
}, nil
}
// Get remaining completions for this task
remainingCompletions, err := s.taskRepo.FindCompletionsByTask(taskID)
if err != nil {
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to query remaining completions after deletion")
return &responses.DeleteWithSummaryResponse{
Data: "completion deleted",
Summary: s.getSummaryForUser(userID),
}, nil
}
// Determine the task's frequency interval
var intervalDays *int
if task.FrequencyID != nil {
frequency, freqErr := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
if freqErr == nil && frequency != nil {
if frequency.Name == "Custom" {
intervalDays = task.CustomIntervalDays
} else {
intervalDays = frequency.Days
}
}
}
if len(remainingCompletions) == 0 {
// No completions remain: restore NextDueDate to the original DueDate
task.NextDueDate = task.DueDate
} else if intervalDays != nil && *intervalDays > 0 {
// Recurring task with remaining completions: recalculate from the latest completion
// remainingCompletions is ordered by completed_at DESC, so index 0 is the latest
latestCompletion := remainingCompletions[0]
nextDue := latestCompletion.CompletedAt.AddDate(0, 0, *intervalDays)
task.NextDueDate = &nextDue
} else {
// One-time task with remaining completions (unusual case): keep NextDueDate as nil
// since the task is still considered completed
task.NextDueDate = nil
}
if err := s.taskRepo.Update(task); err != nil {
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to update task NextDueDate after completion deletion")
// The completion was already deleted; return success but log the update failure
}
return &responses.DeleteWithSummaryResponse{
Data: "completion deleted",
Summary: s.getSummaryForUser(userID),