From a5a2ffd83ef4792c709af685f2b5fe44be1743df Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 20 Feb 2026 12:03:14 +0000 Subject: [PATCH] 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 --- README.md | 18 ++++ backend/handlers/portfolio/images.go | 98 +++++++++++++++++-- backend/handlers/services/services.go | 12 ++- backend/main.go | 42 ++++---- .../lib/components/admin/ImageUpload.svelte | 1 + .../admin/ServicesManagement.svelte | 1 + frontend/src/routes/login/+page.svelte | 5 + frontend/src/routes/portfolio/+page.svelte | 1 + obsidian/Crussell/Crussell Nails.md | 6 ++ 9 files changed, 153 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e66e0ce..02ec33e 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,24 @@ grep -n "r\.\(Get\|Post\|Put\|Delete\|Patch\)" backend/main.go ``` **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 grep -n "r\.Use\|r\.Group" backend/main.go ``` diff --git a/backend/handlers/portfolio/images.go b/backend/handlers/portfolio/images.go index a2c6ed2..e82b0dc 100644 --- a/backend/handlers/portfolio/images.go +++ b/backend/handlers/portfolio/images.go @@ -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 diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go index 917d4f9..9f9084e 100644 --- a/backend/handlers/services/services.go +++ b/backend/handlers/services/services.go @@ -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 { diff --git a/backend/main.go b/backend/main.go index c0471b6..ef7ecee 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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) }) }) diff --git a/frontend/src/lib/components/admin/ImageUpload.svelte b/frontend/src/lib/components/admin/ImageUpload.svelte index 00b18dc..dfbc8ac 100644 --- a/frontend/src/lib/components/admin/ImageUpload.svelte +++ b/frontend/src/lib/components/admin/ImageUpload.svelte @@ -469,6 +469,7 @@ (formData.firstName = formData.firstName.trim())} required @@ -381,6 +382,7 @@ (formData.lastName = formData.lastName.trim())} required @@ -394,6 +396,7 @@ id="phone" type="tel" placeholder="07123 456789 or +44 7123 456789" + maxlength={20} value={formData.phone} oninput={handlePhoneInput} onblur={() => validatePhone(formData.phone)} @@ -428,6 +431,7 @@ id="email" type="email" placeholder="john@example.com" + maxlength={255} bind:value={formData.email} onblur={() => validateEmail(formData.email)} required @@ -443,6 +447,7 @@ id="password" type="password" placeholder="Enter your password" + maxlength={72} bind:value={formData.password} required /> diff --git a/frontend/src/routes/portfolio/+page.svelte b/frontend/src/routes/portfolio/+page.svelte index 92f1c63..166f325 100644 --- a/frontend/src/routes/portfolio/+page.svelte +++ b/frontend/src/routes/portfolio/+page.svelte @@ -599,6 +599,7 @@
{ if ((e as KeyboardEvent).key === 'Enter') applySearch(); diff --git a/obsidian/Crussell/Crussell Nails.md b/obsidian/Crussell/Crussell Nails.md index 003cde3..1c981aa 100644 --- a/obsidian/Crussell/Crussell Nails.md +++ b/obsidian/Crussell/Crussell Nails.md @@ -19,6 +19,12 @@ - [x] DB connection pooling - [x] Refresh token endpoint - Wired to `POST /api/refresh-token`, auto-refresh in frontend - [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 - [x] `/api/bookings` - Full CRUD for authenticated users