feat: Production readiness improvements for WHOOSH council formation
Major security, observability, and configuration improvements:
## Security Hardening
- Implemented configurable CORS (no more wildcards)
- Added comprehensive auth middleware for admin endpoints
- Enhanced webhook HMAC validation
- Added input validation and rate limiting
- Security headers and CSP policies
## Configuration Management
- Made N8N webhook URL configurable (WHOOSH_N8N_BASE_URL)
- Replaced all hardcoded endpoints with environment variables
- Added feature flags for LLM vs heuristic composition
- Gitea fetch hardening with EAGER_FILTER and FULL_RESCAN options
## API Completeness
- Implemented GetCouncilComposition function
- Added GET /api/v1/councils/{id} endpoint
- Council artifacts API (POST/GET /api/v1/councils/{id}/artifacts)
- /admin/health/details endpoint with component status
- Database lookup for repository URLs (no hardcoded fallbacks)
## Observability & Performance
- Added OpenTelemetry distributed tracing with goal/pulse correlation
- Performance optimization database indexes
- Comprehensive health monitoring
- Enhanced logging and error handling
## Infrastructure
- Production-ready P2P discovery (replaces mock implementation)
- Removed unused Redis configuration
- Enhanced Docker Swarm integration
- Added migration files for performance indexes
## Code Quality
- Comprehensive input validation
- Graceful error handling and failsafe fallbacks
- Backwards compatibility maintained
- Following security best practices
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
307
internal/validation/validator.go
Normal file
307
internal/validation/validator.go
Normal file
@@ -0,0 +1,307 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user