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
|
||||
|
||||
Reference in New Issue
Block a user