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
+88 -10
View File
@@ -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
+10 -2
View File
@@ -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 {