Security: add rate limiting, input validation, and filter category
validation Backend: - Add rate limiting middleware (mw/ratelimit.go) - in-memory per-IP limiter - Apply rate limits per endpoint group: - Public read-only: 120/min - Registration: 10/min - Portfolio filters: 60/min - Authenticated users: 120/min - Admin: none (trusted) - Add 256 char input length validation on portfolio endpoints - Validate filter categories exist in DB before querying - Secure GetImage endpoint: only allow UUID or numeric timestamp (15-20 digits) - Remove pattern-based image lookup to prevent enumeration - Add services validation: name (100), duration (1-480), patch test (0-168) Frontend: - Add maxlength=256 to portfolio tag/search inputs - Add maxlength to registration: name (50), email (255), phone (20), password (72) - Add maxlength=100 to service name input
This commit is contained in:
@@ -20,6 +20,38 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const MaxInputLength = 256
|
||||
|
||||
// validateInputLength returns an error if input exceeds max length
|
||||
func validateInputLength(input string) error {
|
||||
if len(input) > MaxInputLength {
|
||||
return fmt.Errorf("input exceeds maximum length of %d characters", MaxInputLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAllowedCategories fetches all unique category prefixes from existing tags
|
||||
func getAllowedCategories(ctx context.Context) (map[string]bool, error) {
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT DISTINCT SPLIT_PART(t, ':', 1) as category
|
||||
FROM images, unnest(tag_names) as t
|
||||
WHERE t LIKE '%:%'
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
categories := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var cat string
|
||||
if err := rows.Scan(&cat); err == nil && cat != "" {
|
||||
categories[cat] = true
|
||||
}
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
@@ -36,6 +68,21 @@ type Tag struct {
|
||||
func ListImages(w http.ResponseWriter, r *http.Request) {
|
||||
tagFilter := r.URL.Query().Get("tag")
|
||||
tagsFilter := r.URL.Query().Get("tags")
|
||||
|
||||
// Validate input length
|
||||
if tagFilter != "" {
|
||||
if err := validateInputLength(tagFilter); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if tagsFilter != "" {
|
||||
if err := validateInputLength(tagsFilter); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
@@ -50,6 +97,14 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate filter categories against allowed list from DB
|
||||
allowedCategories, err := getAllowedCategories(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to get allowed categories: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filterClauses := ""
|
||||
filterArgs := []interface{}{}
|
||||
|
||||
@@ -63,6 +118,18 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
|
||||
if len(matches) == 2 {
|
||||
category := matches[1]
|
||||
value := values[0]
|
||||
|
||||
// Validate category exists
|
||||
if !allowedCategories[category] {
|
||||
continue // Skip invalid categories silently for backward compatibility
|
||||
}
|
||||
|
||||
// Validate input length
|
||||
if err := validateInputLength(category + ":" + value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filterClauses += fmt.Sprintf(" AND $%d = ANY(tag_names)", len(filterArgs)+1)
|
||||
filterArgs = append(filterArgs, category+":"+value)
|
||||
}
|
||||
@@ -620,7 +687,15 @@ func extractKey(url string) string {
|
||||
func GetImage(w http.ResponseWriter, r *http.Request) {
|
||||
imageID := chi.URLParam(r, "id")
|
||||
|
||||
// Validate input length
|
||||
if err := validateInputLength(imageID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var img Image
|
||||
|
||||
// First try UUID lookup
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, url, thumbnail_url, tag_names, created_at
|
||||
FROM images
|
||||
@@ -628,17 +703,20 @@ func GetImage(w http.ResponseWriter, r *http.Request) {
|
||||
`, imageID).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
// Try to find by URL if UUID lookup failed
|
||||
// This allows using timestamps/keys from S3 URLs
|
||||
searchPattern := "%" + imageID + "%"
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, url, thumbnail_url, tag_names, created_at
|
||||
FROM images
|
||||
WHERE url LIKE $1 OR thumbnail_url LIKE $1
|
||||
LIMIT 1
|
||||
`, searchPattern).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt)
|
||||
// If UUID lookup fails, try timestamp lookup (for ?img=timestamp from frontend)
|
||||
// Only allow numeric timestamps (nanosecond Unix epoch) to prevent pattern enumeration
|
||||
timestampMatch, _ := regexp.Compile(`^\d{15,20}$`)
|
||||
if timestampMatch.MatchString(imageID) {
|
||||
searchPattern := "%" + imageID + ".%"
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, url, thumbnail_url, tag_names, created_at
|
||||
FROM images
|
||||
WHERE url LIKE $1 OR thumbnail_url LIKE $1
|
||||
LIMIT 1
|
||||
`, searchPattern).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err != nil || !timestampMatch.MatchString(imageID) {
|
||||
log.Printf("Failed to get image: %v", err)
|
||||
http.Error(w, "Image not found", http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -87,12 +87,20 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Name) > 100 {
|
||||
http.Error(w, "Name must be 100 characters or less", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Price <= 0 {
|
||||
http.Error(w, "Price must be greater than 0", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.PatchTestDurationHours < 0 {
|
||||
http.Error(w, "Patch test duration cannot be negative", http.StatusBadRequest)
|
||||
if req.DurationMinutes <= 0 || req.DurationMinutes > 480 {
|
||||
http.Error(w, "Duration must be between 1 and 480 minutes", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.PatchTestDurationHours < 0 || req.PatchTestDurationHours > 168 {
|
||||
http.Error(w, "Patch test duration must be between 0 and 168 hours", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 {
|
||||
|
||||
+23
-19
@@ -81,38 +81,46 @@ func main() {
|
||||
// All API routes grouped under /api for clarity
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
|
||||
// --- Public Routes ---
|
||||
r.Get("/services", services.ServicesHandler)
|
||||
r.Post("/register", authHandlers.RegisterHandler)
|
||||
// Public read-only
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RateLimit(120, time.Minute))
|
||||
r.Get("/services", services.ServicesHandler)
|
||||
})
|
||||
|
||||
// Registration: 10/min to prevent spam
|
||||
r.With(mw.RateLimit(10, time.Minute)).Post("/register", authHandlers.RegisterHandler)
|
||||
|
||||
// Login: Has its own internal rate limiting
|
||||
r.Post("/login", authHandlers.LoginHandler)
|
||||
|
||||
// --- Portfolio Routes (public read, admin write) ---
|
||||
// Portfolio
|
||||
r.Route("/portfolio", func(r chi.Router) {
|
||||
r.Get("/images", portfolio.ListImages)
|
||||
r.Get("/tags", portfolio.ListTags)
|
||||
r.Get("/filters", portfolio.ListFilters)
|
||||
r.Get("/images/{id}", portfolio.GetImage)
|
||||
r.With(mw.RateLimit(60, time.Minute)).Get("/filters", portfolio.ListFilters)
|
||||
r.With(mw.RateLimit(120, time.Minute)).Get("/images/{id}", portfolio.GetImage)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Use(mw.RequireAdmin)
|
||||
r.Use(mw.RateLimit(60, time.Minute))
|
||||
r.Post("/images", portfolio.UploadImage)
|
||||
r.Delete("/images/{id}", portfolio.DeleteImage)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Scheduling Routes ---
|
||||
// Scheduling
|
||||
r.Route("/scheduling", func(r chi.Router) {
|
||||
// Public GET routes
|
||||
r.Use(mw.RateLimit(120, time.Minute))
|
||||
r.Get("/default-hours", scheduling.GetDefaultHours)
|
||||
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
|
||||
r.Get("/working-hours", scheduling.GetWorkingHours)
|
||||
r.Get("/available-hours", scheduling.GetAvailableHours)
|
||||
|
||||
// Admin-only scheduling modifications
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Use(mw.RequireAdmin)
|
||||
r.Use(mw.RateLimit(60, time.Minute))
|
||||
|
||||
r.Put("/default-hours", scheduling.UpdateDefaultHours)
|
||||
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup)
|
||||
@@ -121,9 +129,10 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// --- Protected routes (any authenticated user) ---
|
||||
// Authenticated users
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Use(mw.RateLimit(120, time.Minute))
|
||||
|
||||
r.Post("/refresh-token", authHandlers.RefreshTokenHandler)
|
||||
|
||||
@@ -132,17 +141,16 @@ func main() {
|
||||
r.Delete("/user/account", user.DeleteAccountHandler)
|
||||
r.Get("/user/loyalty", user.GetLoyaltyHandler)
|
||||
|
||||
// Booking routes for authenticated users
|
||||
r.Route("/bookings", func(r chi.Router) {
|
||||
r.Get("/", bookings.GetAllUserBookingsHandler)
|
||||
r.Post("/", bookings.CreateBookingHandler)
|
||||
r.Get("/", bookings.GetAllUserBookingsHandler)
|
||||
r.Get("/{id}", bookings.GetBookingHandler)
|
||||
r.Put("/{id}", bookings.EditBookingHandler)
|
||||
r.Delete("/{id}", bookings.DeleteBookingHandler)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Admin-only routes ---
|
||||
// Admin-only (no rate limit - trusted users with authenticated sessions)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Use(mw.RequireAdmin)
|
||||
@@ -157,11 +165,11 @@ func main() {
|
||||
r.Route("/admin/bookings", func(r chi.Router) {
|
||||
r.Get("/", bookings.GetAllAdminBookingsHandler)
|
||||
r.Post("/", bookings.AdminCreateBookingForUserHandler)
|
||||
r.Get("/search", bookings.SearchAdminBookingsHandler)
|
||||
r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler)
|
||||
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
|
||||
r.Get("/{id}", bookings.GetAdminBookingHandler)
|
||||
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
|
||||
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) // HERE
|
||||
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
|
||||
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
|
||||
})
|
||||
|
||||
@@ -176,12 +184,8 @@ func main() {
|
||||
r.Get("/pending-approvals", today.GetPendingApprovalsHandler)
|
||||
})
|
||||
|
||||
// --- Admin Notifications ---
|
||||
r.Route("/admin/notifications", func(r chi.Router) {
|
||||
// List all unacknowledged notifications
|
||||
r.Get("/", notifications.GetNotifications)
|
||||
|
||||
// Acknowledge a single notification
|
||||
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user