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:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user