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>
307 lines
8.3 KiB
Go
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)
|
|
})
|
|
} |