fix: critical security vulnerabilities

- Fix type assertion panics in auth.go (9 handlers)
  * GetProfile, UpdateProfile, UpdateTheme, UpdateUsername
  * UpdateEmail, UpdatePassword, DeleteAccount
  * UpdateScanSettings, GetScanSettings, Register admin check
  * Replace c.Get("user_id").(string) with MustGetAuthenticatedUser()

- Fix type assertion panic in library.go
  * GetUserVisibleLibraries now uses MustGetAuthenticatedUser()

- Add path traversal protection to AddLibraryFolder
  * Detect and block ".." in paths
  * Clean paths with filepath.Clean()
  * Verify path is a directory before adding

- Remove debug logging from Login handler
  * Removed all fmt.Printf statements
  * No more plaintext password logging

- Create safe context helper functions
  * internal/handlers/context.go added
  * GetAuthenticatedUser() for safe retrieval
  * MustGetAuthenticatedUser() for post-auth middleware

Security: Critical
Tests: All 62 integration tests pass
Breaking: None - backward compatible
This commit is contained in:
2026-01-30 08:58:43 -05:00
parent 8a8a81ef78
commit 420af7978a
6 changed files with 683 additions and 104 deletions
+38 -96
View File
@@ -186,22 +186,12 @@ func (h *AuthHandler) Register(c echo.Context) error {
// Role-based restrictions: only admins can create admin users if any admin already exists
if userRole == "admin" && adminExists {
// Check if current user is admin (requires authentication)
userID := c.Get("user_id")
if userID == nil {
// Not authenticated - cannot create admin user if admins exist
user, ok := c.Get("user").(database.Users)
if !ok || user.Role != "admin" {
// Not authenticated or not admin - cannot create admin user if admins exist
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Only existing administrators can create admin accounts</div>`)
}
return c.JSON(http.StatusForbidden, map[string]string{"error": "only existing administrators can create admin accounts"})
}
// User is authenticated - check their role
userRoleAuth := c.Get("user_role").(string)
if userRoleAuth != "admin" {
// Authenticated but not admin - cannot create admin accounts
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Only administrators can create admin accounts</div>`)
}
return c.JSON(http.StatusForbidden, map[string]string{"error": "only administrators can create admin accounts"})
}
}
@@ -285,27 +275,20 @@ window.location.href = '/bookshelf';
// Login handles POST /api/auth/login
func (h *AuthHandler) Login(c echo.Context) error {
// Debug logging
fmt.Printf("Login request - Content-Type: %s\n", c.Request().Header.Get("Content-Type"))
fmt.Printf("Form values - login: %s, password: %s\n", c.FormValue("login"), c.FormValue("password"))
// Try form data first (HTMX), then JSON (Bruno)
login := c.FormValue("login")
password := c.FormValue("password")
if login == "" || password == "" {
fmt.Printf("Form values empty, trying JSON bind\n")
// Fallback to JSON binding
req := LoginRequest{}
if err := c.Bind(&req); err != nil {
fmt.Printf("JSON bind error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Invalid request</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
fmt.Printf("Validation error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
@@ -313,12 +296,10 @@ func (h *AuthHandler) Login(c echo.Context) error {
}
login = req.Login
password = req.Password
fmt.Printf("JSON bind success - login: %s\n", login)
}
req := LoginRequest{Login: login, Password: password}
if err := c.Validate(&req); err != nil {
fmt.Printf("Final validation error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
@@ -443,19 +424,7 @@ window.location.href = '/bookshelf';
// GetProfile handles GET /api/auth/profile
func (h *AuthHandler) GetProfile(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user, err := h.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
user := MustGetAuthenticatedUser(c)
firstName := ""
if user.FirstName.Valid {
@@ -477,19 +446,15 @@ func (h *AuthHandler) GetProfile(c echo.Context) error {
// UpdateProfile handles PUT /api/auth/profile
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user := MustGetAuthenticatedUser(c)
var req UpdateProfileRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
err = h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
ID: user.ID,
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
})
@@ -618,11 +583,7 @@ type UpdateThemeRequest struct {
// UpdateTheme handles PUT /api/auth/theme
func (h *AuthHandler) UpdateTheme(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user := MustGetAuthenticatedUser(c)
var req UpdateThemeRequest
if err := c.Bind(&req); err != nil {
@@ -632,8 +593,8 @@ func (h *AuthHandler) UpdateTheme(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
err = h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
ID: user.ID,
Theme: pgtype.Text{String: req.Theme, Valid: req.Theme != ""},
})
if err != nil {
@@ -649,11 +610,7 @@ type UpdateUsernameRequest struct {
// UpdateUsername handles PUT /api/auth/username
func (h *AuthHandler) UpdateUsername(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user := MustGetAuthenticatedUser(c)
var req UpdateUsernameRequest
if err := c.Bind(&req); err != nil {
@@ -665,13 +622,13 @@ func (h *AuthHandler) UpdateUsername(c echo.Context) error {
// Check if username is already taken by another user
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
if err == nil && uuid.UUID(existingUser.ID.Bytes) != userUUID {
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
}
// Update username
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
ID: user.ID,
Username: req.Username,
})
if err != nil {
@@ -687,11 +644,7 @@ type UpdateEmailRequest struct {
// UpdateEmail handles PUT /api/auth/email
func (h *AuthHandler) UpdateEmail(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user := MustGetAuthenticatedUser(c)
var req UpdateEmailRequest
if err := c.Bind(&req); err != nil {
@@ -703,13 +656,13 @@ func (h *AuthHandler) UpdateEmail(c echo.Context) error {
// Check if email is already taken by another user
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err == nil && uuid.UUID(existingUser.ID.Bytes) != userUUID {
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
}
// Update email
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
ID: user.ID,
Email: req.Email,
})
if err != nil {
@@ -727,11 +680,7 @@ type UpdatePasswordRequest struct {
// UpdatePassword handles PUT /api/auth/password
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user := MustGetAuthenticatedUser(c)
var req UpdatePasswordRequest
if err := c.Bind(&req); err != nil {
@@ -747,7 +696,7 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
}
// Get current user's password hash
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), user.ID)
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
@@ -768,7 +717,7 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
// Update password
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
ID: user.ID,
PasswordHash: string(hashedPassword),
})
if err != nil {
@@ -781,23 +730,25 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
// DeleteAccount handles DELETE /api/auth/account
// Supports self-deletion or admin deletion of other users
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
currentUser := MustGetAuthenticatedUser(c)
// Get target user ID from query parameter (for admin override) or use current user
targetUserID := c.QueryParam("user_id")
userID := c.Get("user_id").(string)
var targetUserUUID pgtype.UUID
// If admin override is used, validate admin and use target
if targetUserID != "" {
// Admin override mode - check if current user is admin
userRole := c.Get("user_role").(string)
if userRole != "admin" {
if currentUser.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
userID = targetUserID
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
} else {
targetUserUUID = currentUser.ID
}
// Check if this is the last admin user - prevent deletion
@@ -814,8 +765,7 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
adminCount++
}
// Find target user details
userUUIDStr := uuid.UUID(user.ID.Bytes).String()
if userUUIDStr == userID {
if user.ID.Bytes == targetUserUUID.Bytes {
targetUserRole = user.Role
}
}
@@ -829,7 +779,7 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
}
// Delete user (this will cascade to delete all related data)
err = h.db.DeleteUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
err = h.db.DeleteUser(c.Request().Context(), targetUserUUID)
if err != nil {
if err == pgx.ErrNoRows {
if c.Request().Header.Get("HX-Request") == "true" {
@@ -845,7 +795,7 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
// Create success message based on context
var message string
if targetUserID != "" && userID != c.Get("user_id").(string) {
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
message = "user account deleted successfully"
} else {
message = "account deleted successfully"
@@ -861,11 +811,7 @@ type UpdateScanSettingsRequest struct {
// UpdateScanSettings handles PUT /api/library/scan-settings
func (h *AuthHandler) UpdateScanSettings(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user := MustGetAuthenticatedUser(c)
var req UpdateScanSettingsRequest
if err := c.Bind(&req); err != nil {
@@ -875,8 +821,8 @@ func (h *AuthHandler) UpdateScanSettings(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
err = h.db.UpdateScanSettings(c.Request().Context(), database.UpdateScanSettingsParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
err := h.db.UpdateScanSettings(c.Request().Context(), database.UpdateScanSettingsParams{
ID: user.ID,
ScanFrequencyMinutes: pgtype.Int4{Int32: req.ScanFrequencyMinutes, Valid: true},
AutoScanEnabled: pgtype.Bool{Bool: req.AutoScanEnabled, Valid: true},
})
@@ -889,13 +835,9 @@ func (h *AuthHandler) UpdateScanSettings(c echo.Context) error {
// GetScanSettings handles GET /api/library/scan-settings
func (h *AuthHandler) GetScanSettings(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user := MustGetAuthenticatedUser(c)
settings, err := h.db.GetScanSettings(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
settings, err := h.db.GetScanSettings(c.Request().Context(), user.ID)
if err != nil {
if err == pgx.ErrNoRows {
// If no settings found, return defaults