Files
WHOOSH/internal/validation/validator.go
Claude Code 131868bdca 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>
2025-09-12 20:34:17 +10:00

307 lines
8.3 KiB
Go

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)
})
}