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:
2026-02-20 12:03:14 +00:00
parent f9eec94f2f
commit a5a2ffd83e
9 changed files with 153 additions and 31 deletions
+18
View File
@@ -264,6 +264,24 @@ grep -n "r\.\(Get\|Post\|Put\|Delete\|Patch\)" backend/main.go
``` ```
**Middleware Chain:** **Middleware Chain:**
- RequestID, RealIP, Logger, Recoverer, Timeout(15s)
- Security headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection)
- Rate limiting (per-endpoint):
- Public read-only: 120/min
- Registration: 10/min
- Portfolio filters: 60/min
- Portfolio single image: 120/min
- Portfolio admin: 60/min
- Authenticated users: 120/min
- Admin search: 60/min
- Admin-only routes: none (trusted)
**Input Validation:**
- Backend validates all inputs against DB schema constraints
- Frontend adds maxlength attributes matching DB limits
- Registration: name (1-50), email (255), phone (20), password (72)
- Services: name (100), price (>0), duration (1-480), patch test (0-168), age (0-100)
- Portfolio: tags/filters (256 char max)
```bash ```bash
grep -n "r\.Use\|r\.Group" backend/main.go grep -n "r\.Use\|r\.Group" backend/main.go
``` ```
+82 -4
View File
@@ -20,6 +20,38 @@ import (
"github.com/go-chi/chi/v5" "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 { type Image struct {
ID string `json:"id"` ID string `json:"id"`
URL string `json:"url"` URL string `json:"url"`
@@ -36,6 +68,21 @@ type Tag struct {
func ListImages(w http.ResponseWriter, r *http.Request) { func ListImages(w http.ResponseWriter, r *http.Request) {
tagFilter := r.URL.Query().Get("tag") tagFilter := r.URL.Query().Get("tag")
tagsFilter := r.URL.Query().Get("tags") 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 limit := 20
offset := 0 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 := "" filterClauses := ""
filterArgs := []interface{}{} filterArgs := []interface{}{}
@@ -63,6 +118,18 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
if len(matches) == 2 { if len(matches) == 2 {
category := matches[1] category := matches[1]
value := values[0] 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) filterClauses += fmt.Sprintf(" AND $%d = ANY(tag_names)", len(filterArgs)+1)
filterArgs = append(filterArgs, category+":"+value) filterArgs = append(filterArgs, category+":"+value)
} }
@@ -620,7 +687,15 @@ func extractKey(url string) string {
func GetImage(w http.ResponseWriter, r *http.Request) { func GetImage(w http.ResponseWriter, r *http.Request) {
imageID := chi.URLParam(r, "id") 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 var img Image
// First try UUID lookup
err := db.DB.QueryRow(r.Context(), ` err := db.DB.QueryRow(r.Context(), `
SELECT id, url, thumbnail_url, tag_names, created_at SELECT id, url, thumbnail_url, tag_names, created_at
FROM images 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) `, imageID).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt)
if err != nil { if err != nil {
// Try to find by URL if UUID lookup failed // If UUID lookup fails, try timestamp lookup (for ?img=timestamp from frontend)
// This allows using timestamps/keys from S3 URLs // Only allow numeric timestamps (nanosecond Unix epoch) to prevent pattern enumeration
searchPattern := "%" + imageID + "%" timestampMatch, _ := regexp.Compile(`^\d{15,20}$`)
if timestampMatch.MatchString(imageID) {
searchPattern := "%" + imageID + ".%"
err = db.DB.QueryRow(r.Context(), ` err = db.DB.QueryRow(r.Context(), `
SELECT id, url, thumbnail_url, tag_names, created_at SELECT id, url, thumbnail_url, tag_names, created_at
FROM images FROM images
WHERE url LIKE $1 OR thumbnail_url LIKE $1 WHERE url LIKE $1 OR thumbnail_url LIKE $1
LIMIT 1 LIMIT 1
`, searchPattern).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt) `, 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) log.Printf("Failed to get image: %v", err)
http.Error(w, "Image not found", http.StatusNotFound) http.Error(w, "Image not found", http.StatusNotFound)
return return
+10 -2
View File
@@ -87,12 +87,20 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Name is required", http.StatusBadRequest) http.Error(w, "Name is required", http.StatusBadRequest)
return return
} }
if len(req.Name) > 100 {
http.Error(w, "Name must be 100 characters or less", http.StatusBadRequest)
return
}
if req.Price <= 0 { if req.Price <= 0 {
http.Error(w, "Price must be greater than 0", http.StatusBadRequest) http.Error(w, "Price must be greater than 0", http.StatusBadRequest)
return return
} }
if req.PatchTestDurationHours < 0 { if req.DurationMinutes <= 0 || req.DurationMinutes > 480 {
http.Error(w, "Patch test duration cannot be negative", http.StatusBadRequest) 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 return
} }
if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 { if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 {
+22 -18
View File
@@ -81,38 +81,46 @@ func main() {
// All API routes grouped under /api for clarity // All API routes grouped under /api for clarity
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
// --- Public Routes --- // Public read-only
r.Group(func(r chi.Router) {
r.Use(mw.RateLimit(120, time.Minute))
r.Get("/services", services.ServicesHandler) r.Get("/services", services.ServicesHandler)
r.Post("/register", authHandlers.RegisterHandler) })
// 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) r.Post("/login", authHandlers.LoginHandler)
// --- Portfolio Routes (public read, admin write) --- // Portfolio
r.Route("/portfolio", func(r chi.Router) { r.Route("/portfolio", func(r chi.Router) {
r.Get("/images", portfolio.ListImages) r.Get("/images", portfolio.ListImages)
r.Get("/tags", portfolio.ListTags) r.Get("/tags", portfolio.ListTags)
r.Get("/filters", portfolio.ListFilters) r.With(mw.RateLimit(60, time.Minute)).Get("/filters", portfolio.ListFilters)
r.Get("/images/{id}", portfolio.GetImage) r.With(mw.RateLimit(120, time.Minute)).Get("/images/{id}", portfolio.GetImage)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth) r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin) r.Use(mw.RequireAdmin)
r.Use(mw.RateLimit(60, time.Minute))
r.Post("/images", portfolio.UploadImage) r.Post("/images", portfolio.UploadImage)
r.Delete("/images/{id}", portfolio.DeleteImage) r.Delete("/images/{id}", portfolio.DeleteImage)
}) })
}) })
// --- Scheduling Routes --- // Scheduling
r.Route("/scheduling", func(r chi.Router) { 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("/default-hours", scheduling.GetDefaultHours)
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups) r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
r.Get("/working-hours", scheduling.GetWorkingHours) r.Get("/working-hours", scheduling.GetWorkingHours)
r.Get("/available-hours", scheduling.GetAvailableHours) r.Get("/available-hours", scheduling.GetAvailableHours)
// Admin-only scheduling modifications
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth) r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin) r.Use(mw.RequireAdmin)
r.Use(mw.RateLimit(60, time.Minute))
r.Put("/default-hours", scheduling.UpdateDefaultHours) r.Put("/default-hours", scheduling.UpdateDefaultHours)
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup) 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.Group(func(r chi.Router) {
r.Use(mw.RequireAuth) r.Use(mw.RequireAuth)
r.Use(mw.RateLimit(120, time.Minute))
r.Post("/refresh-token", authHandlers.RefreshTokenHandler) r.Post("/refresh-token", authHandlers.RefreshTokenHandler)
@@ -132,17 +141,16 @@ func main() {
r.Delete("/user/account", user.DeleteAccountHandler) r.Delete("/user/account", user.DeleteAccountHandler)
r.Get("/user/loyalty", user.GetLoyaltyHandler) r.Get("/user/loyalty", user.GetLoyaltyHandler)
// Booking routes for authenticated users
r.Route("/bookings", func(r chi.Router) { r.Route("/bookings", func(r chi.Router) {
r.Get("/", bookings.GetAllUserBookingsHandler)
r.Post("/", bookings.CreateBookingHandler) r.Post("/", bookings.CreateBookingHandler)
r.Get("/", bookings.GetAllUserBookingsHandler)
r.Get("/{id}", bookings.GetBookingHandler) r.Get("/{id}", bookings.GetBookingHandler)
r.Put("/{id}", bookings.EditBookingHandler) r.Put("/{id}", bookings.EditBookingHandler)
r.Delete("/{id}", bookings.DeleteBookingHandler) 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.Group(func(r chi.Router) {
r.Use(mw.RequireAuth) r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin) r.Use(mw.RequireAdmin)
@@ -157,11 +165,11 @@ func main() {
r.Route("/admin/bookings", func(r chi.Router) { r.Route("/admin/bookings", func(r chi.Router) {
r.Get("/", bookings.GetAllAdminBookingsHandler) r.Get("/", bookings.GetAllAdminBookingsHandler)
r.Post("/", bookings.AdminCreateBookingForUserHandler) 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("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
r.Get("/{id}", bookings.GetAdminBookingHandler) r.Get("/{id}", bookings.GetAdminBookingHandler)
r.Put("/{id}/progress", bookings.ProgressBookingHandler) 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) r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
}) })
@@ -176,12 +184,8 @@ func main() {
r.Get("/pending-approvals", today.GetPendingApprovalsHandler) r.Get("/pending-approvals", today.GetPendingApprovalsHandler)
}) })
// --- Admin Notifications ---
r.Route("/admin/notifications", func(r chi.Router) { r.Route("/admin/notifications", func(r chi.Router) {
// List all unacknowledged notifications
r.Get("/", notifications.GetNotifications) r.Get("/", notifications.GetNotifications)
// Acknowledge a single notification
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification) r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
}) })
}) })
@@ -469,6 +469,7 @@
<input <input
type="search" type="search"
maxlength={256}
enterkeyhint="done" enterkeyhint="done"
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
@@ -551,6 +551,7 @@
<Input <Input
id="service-name" id="service-name"
type="text" type="text"
maxlength={100}
placeholder="e.g., Haircut, Color, Blowdry" placeholder="e.g., Haircut, Color, Blowdry"
bind:value={newService.name} bind:value={newService.name}
onblur={validateNameField} onblur={validateNameField}
+5
View File
@@ -371,6 +371,7 @@
<Input <Input
id="firstName" id="firstName"
placeholder="John" placeholder="John"
maxlength={50}
bind:value={formData.firstName} bind:value={formData.firstName}
onblur={() => (formData.firstName = formData.firstName.trim())} onblur={() => (formData.firstName = formData.firstName.trim())}
required required
@@ -381,6 +382,7 @@
<Input <Input
id="lastName" id="lastName"
placeholder="Doe" placeholder="Doe"
maxlength={50}
bind:value={formData.lastName} bind:value={formData.lastName}
onblur={() => (formData.lastName = formData.lastName.trim())} onblur={() => (formData.lastName = formData.lastName.trim())}
required required
@@ -394,6 +396,7 @@
id="phone" id="phone"
type="tel" type="tel"
placeholder="07123 456789 or +44 7123 456789" placeholder="07123 456789 or +44 7123 456789"
maxlength={20}
value={formData.phone} value={formData.phone}
oninput={handlePhoneInput} oninput={handlePhoneInput}
onblur={() => validatePhone(formData.phone)} onblur={() => validatePhone(formData.phone)}
@@ -428,6 +431,7 @@
id="email" id="email"
type="email" type="email"
placeholder="john@example.com" placeholder="john@example.com"
maxlength={255}
bind:value={formData.email} bind:value={formData.email}
onblur={() => validateEmail(formData.email)} onblur={() => validateEmail(formData.email)}
required required
@@ -443,6 +447,7 @@
id="password" id="password"
type="password" type="password"
placeholder="Enter your password" placeholder="Enter your password"
maxlength={72}
bind:value={formData.password} bind:value={formData.password}
required required
/> />
@@ -599,6 +599,7 @@
<div class="flex gap-2 lg:border-l lg:pl-4"> <div class="flex gap-2 lg:border-l lg:pl-4">
<Input <Input
placeholder="Search tags" placeholder="Search tags"
maxlength={256}
bind:value={searchQuery} bind:value={searchQuery}
onkeydown={(e) => { onkeydown={(e) => {
if ((e as KeyboardEvent).key === 'Enter') applySearch(); if ((e as KeyboardEvent).key === 'Enter') applySearch();
+6
View File
@@ -19,6 +19,12 @@
- [x] DB connection pooling - [x] DB connection pooling
- [x] Refresh token endpoint - Wired to `POST /api/refresh-token`, auto-refresh in frontend - [x] Refresh token endpoint - Wired to `POST /api/refresh-token`, auto-refresh in frontend
- [x] Login rate limiting (1 attempt per 5 seconds) - [x] Login rate limiting (1 attempt per 5 seconds)
- [x] Global rate limiting middleware (per-endpoint: 120/min public, 10/min register, 60/min filters, none admin)
- [x] Security headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection)
- [x] Input validation on all endpoints:
- Registration: name (1-50), email (255), phone (20), password (72), age 16+
- Services: name (100), price (>0), duration (1-480), patch test (0-168), age (0-100)
- Portfolio: tags/filters (256 char max), filter category validation, image ID pattern security
#### Booking System #### Booking System
- [x] `/api/bookings` - Full CRUD for authenticated users - [x] `/api/bookings` - Full CRUD for authenticated users