package validation import ( "encoding/json" "fmt" "net/http" "net/url" "regexp" "strings" "github.com/go-chi/render" "github.com/rs/zerolog/log" ) // Common validation patterns var ( // AlphaNumeric allows letters, numbers, hyphens and underscores AlphaNumeric = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) // ProjectName allows alphanumeric, spaces, hyphens, underscores (max 100 chars) ProjectName = regexp.MustCompile(`^[a-zA-Z0-9\s_-]{1,100}$`) // GitURL validates basic git URL structure GitURL = regexp.MustCompile(`^https?:\/\/[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(?:\.git)?$`) // TaskTitle allows reasonable task title characters (max 200 chars) TaskTitle = regexp.MustCompile(`^[a-zA-Z0-9\s.,!?()_-]{1,200}$`) // AgentID should be alphanumeric with hyphens (max 50 chars) AgentID = regexp.MustCompile(`^[a-zA-Z0-9-]{1,50}$`) // UUID pattern for council IDs, task IDs, etc. UUID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) ) // ValidationError represents a validation error type ValidationError struct { Field string `json:"field"` Message string `json:"message"` } // ValidationErrors is a slice of validation errors type ValidationErrors []ValidationError func (v ValidationErrors) Error() string { if len(v) == 0 { return "" } if len(v) == 1 { return fmt.Sprintf("%s: %s", v[0].Field, v[0].Message) } return fmt.Sprintf("validation failed for %d fields", len(v)) } // Validator provides request validation utilities type Validator struct { maxBodySize int64 } // NewValidator creates a new validator with default settings func NewValidator() *Validator { return &Validator{ maxBodySize: 1024 * 1024, // 1MB default } } // WithMaxBodySize sets the maximum request body size func (v *Validator) WithMaxBodySize(size int64) *Validator { v.maxBodySize = size return v } // DecodeAndValidateJSON safely decodes JSON with size limits and validation func (v *Validator) DecodeAndValidateJSON(r *http.Request, dest interface{}) error { // Limit request body size to prevent DoS attacks r.Body = http.MaxBytesReader(nil, r.Body, v.maxBodySize) // Decode JSON if err := json.NewDecoder(r.Body).Decode(dest); err != nil { log.Warn().Err(err).Msg("JSON decode error") return fmt.Errorf("invalid JSON: %w", err) } return nil } // ValidateProjectRequest validates project creation/update requests func ValidateProjectRequest(req map[string]interface{}) ValidationErrors { var errors ValidationErrors // Validate name name, ok := req["name"].(string) if !ok || name == "" { errors = append(errors, ValidationError{ Field: "name", Message: "name is required", }) } else if !ProjectName.MatchString(name) { errors = append(errors, ValidationError{ Field: "name", Message: "name contains invalid characters or is too long (max 100 chars)", }) } // Validate repo_url repoURL, ok := req["repo_url"].(string) if !ok || repoURL == "" { errors = append(errors, ValidationError{ Field: "repo_url", Message: "repo_url is required", }) } else { if !GitURL.MatchString(repoURL) { errors = append(errors, ValidationError{ Field: "repo_url", Message: "invalid git repository URL format", }) } else { // Additional URL validation if _, err := url.Parse(repoURL); err != nil { errors = append(errors, ValidationError{ Field: "repo_url", Message: "malformed URL", }) } } } // Validate optional description if desc, exists := req["description"]; exists { if descStr, ok := desc.(string); ok && len(descStr) > 1000 { errors = append(errors, ValidationError{ Field: "description", Message: "description too long (max 1000 chars)", }) } } return errors } // ValidateTaskRequest validates task creation/update requests func ValidateTaskRequest(req map[string]interface{}) ValidationErrors { var errors ValidationErrors // Validate title title, ok := req["title"].(string) if !ok || title == "" { errors = append(errors, ValidationError{ Field: "title", Message: "title is required", }) } else if !TaskTitle.MatchString(title) { errors = append(errors, ValidationError{ Field: "title", Message: "title contains invalid characters or is too long (max 200 chars)", }) } // Validate description description, ok := req["description"].(string) if !ok || description == "" { errors = append(errors, ValidationError{ Field: "description", Message: "description is required", }) } else if len(description) > 5000 { errors = append(errors, ValidationError{ Field: "description", Message: "description too long (max 5000 chars)", }) } // Validate priority (if provided) if priority, exists := req["priority"]; exists { if priorityStr, ok := priority.(string); ok { validPriorities := []string{"low", "medium", "high", "critical"} isValid := false for _, valid := range validPriorities { if strings.ToLower(priorityStr) == valid { isValid = true break } } if !isValid { errors = append(errors, ValidationError{ Field: "priority", Message: "priority must be one of: low, medium, high, critical", }) } } } return errors } // ValidateAgentRequest validates agent registration requests func ValidateAgentRequest(req map[string]interface{}) ValidationErrors { var errors ValidationErrors // Validate agent_id agentID, ok := req["agent_id"].(string) if !ok || agentID == "" { errors = append(errors, ValidationError{ Field: "agent_id", Message: "agent_id is required", }) } else if !AgentID.MatchString(agentID) { errors = append(errors, ValidationError{ Field: "agent_id", Message: "agent_id contains invalid characters or is too long (max 50 chars)", }) } // Validate capabilities (if provided) if capabilities, exists := req["capabilities"]; exists { if capArray, ok := capabilities.([]interface{}); ok { if len(capArray) > 50 { errors = append(errors, ValidationError{ Field: "capabilities", Message: "too many capabilities (max 50)", }) } for i, cap := range capArray { if capStr, ok := cap.(string); !ok || len(capStr) > 100 { errors = append(errors, ValidationError{ Field: fmt.Sprintf("capabilities[%d]", i), Message: "capability must be string with max 100 chars", }) } } } } return errors } // ValidatePathParameter validates URL path parameters func ValidatePathParameter(param, value, paramType string) error { if value == "" { return fmt.Errorf("%s is required", param) } switch paramType { case "uuid": if !UUID.MatchString(value) { return fmt.Errorf("invalid %s format (must be UUID)", param) } case "alphanumeric": if !AlphaNumeric.MatchString(value) { return fmt.Errorf("invalid %s format (alphanumeric only)", param) } case "agent_id": if !AgentID.MatchString(value) { return fmt.Errorf("invalid %s format", param) } } return nil } // SanitizeString removes potentially dangerous characters func SanitizeString(input string) string { // Remove null bytes input = strings.ReplaceAll(input, "\x00", "") // Trim whitespace input = strings.TrimSpace(input) return input } // ValidateAndRespond validates data and responds with errors if validation fails func (v *Validator) ValidateAndRespond(w http.ResponseWriter, r *http.Request, errors ValidationErrors) bool { if len(errors) > 0 { log.Warn().Interface("errors", errors).Msg("Validation failed") render.Status(r, http.StatusBadRequest) render.JSON(w, r, map[string]interface{}{ "error": "validation failed", "errors": errors, }) return false } return true } // SecurityHeaders adds security headers to the response func SecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Content Security Policy w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'") // X-Frame-Options w.Header().Set("X-Frame-Options", "DENY") // X-Content-Type-Options w.Header().Set("X-Content-Type-Options", "nosniff") // X-XSS-Protection w.Header().Set("X-XSS-Protection", "1; mode=block") // Referrer Policy w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") next.ServeHTTP(w, r) }) }