This commit is contained in:
2025-08-23 22:31:47 -04:00
parent 9ca9c53baf
commit e5bccc85c2
22 changed files with 2405 additions and 209 deletions

View File

@ -0,0 +1,375 @@
package validation
import (
"fmt"
"net/url"
"regexp"
"strings"
"unicode"
"go.uber.org/zap"
)
// Validator provides comprehensive input validation
type Validator struct {
logger *zap.Logger
}
// NewValidator creates a new input validator
func NewValidator(logger *zap.Logger) *Validator {
return &Validator{
logger: logger,
}
}
// ValidationError represents a validation error
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
Value string `json:"value,omitempty"`
}
func (e ValidationError) Error() string {
return fmt.Sprintf("validation error for field '%s': %s", e.Field, e.Message)
}
// ValidationResult holds the result of validation
type ValidationResult struct {
Valid bool `json:"valid"`
Errors []ValidationError `json:"errors"`
}
// AddError adds a validation error
func (vr *ValidationResult) AddError(field, message, value string) {
vr.Valid = false
vr.Errors = append(vr.Errors, ValidationError{
Field: field,
Message: message,
Value: value,
})
}
// Regular expressions for validation
var (
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
appIDRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$`)
tokenPrefixRegex = regexp.MustCompile(`^[A-Z]{2,4}$`)
permissionRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9._]*[a-zA-Z0-9]$`)
)
// ValidateEmail validates email addresses
func (v *Validator) ValidateEmail(email string) *ValidationResult {
result := &ValidationResult{Valid: true}
if email == "" {
result.AddError("email", "Email is required", "")
return result
}
if len(email) > 254 {
result.AddError("email", "Email too long (max 254 characters)", email)
return result
}
if !emailRegex.MatchString(email) {
result.AddError("email", "Invalid email format", email)
return result
}
// Additional email security checks
if strings.Contains(email, "..") {
result.AddError("email", "Email contains consecutive dots", email)
return result
}
// Check for potentially dangerous characters
dangerousChars := []string{"<", ">", "\"", "'", "&", ";", "|", "`"}
for _, char := range dangerousChars {
if strings.Contains(email, char) {
result.AddError("email", "Email contains invalid characters", email)
return result
}
}
return result
}
// ValidateAppID validates application IDs
func (v *Validator) ValidateAppID(appID string) *ValidationResult {
result := &ValidationResult{Valid: true}
if appID == "" {
result.AddError("app_id", "Application ID is required", "")
return result
}
if len(appID) < 3 || len(appID) > 100 {
result.AddError("app_id", "Application ID must be between 3 and 100 characters", appID)
return result
}
if !appIDRegex.MatchString(appID) {
result.AddError("app_id", "Application ID must start and end with alphanumeric characters and contain only letters, numbers, dots, hyphens, and underscores", appID)
return result
}
// Check for reserved names
reservedNames := []string{"admin", "root", "system", "internal", "api", "www", "mail", "ftp"}
for _, reserved := range reservedNames {
if strings.EqualFold(appID, reserved) {
result.AddError("app_id", "Application ID cannot be a reserved name", appID)
return result
}
}
return result
}
// ValidateURL validates URLs
func (v *Validator) ValidateURL(urlStr, fieldName string) *ValidationResult {
result := &ValidationResult{Valid: true}
if urlStr == "" {
result.AddError(fieldName, "URL is required", "")
return result
}
if len(urlStr) > 2000 {
result.AddError(fieldName, "URL too long (max 2000 characters)", urlStr)
return result
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
result.AddError(fieldName, "Invalid URL format", urlStr)
return result
}
// Validate scheme
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
result.AddError(fieldName, "URL must use http or https scheme", urlStr)
return result
}
// Security: Require HTTPS in production (configurable)
if parsedURL.Scheme != "https" {
v.logger.Warn("Non-HTTPS URL provided", zap.String("url", urlStr))
// In strict mode, this would be an error
// result.AddError(fieldName, "HTTPS is required", urlStr)
}
// Validate host
if parsedURL.Host == "" {
result.AddError(fieldName, "URL must have a valid host", urlStr)
return result
}
// Security: Block localhost and private IPs in production
if v.isPrivateOrLocalhost(parsedURL.Host) {
result.AddError(fieldName, "URLs pointing to private or localhost addresses are not allowed", urlStr)
return result
}
return result
}
// ValidatePermissions validates a list of permissions
func (v *Validator) ValidatePermissions(permissions []string) *ValidationResult {
result := &ValidationResult{Valid: true}
if len(permissions) == 0 {
result.AddError("permissions", "At least one permission is required", "")
return result
}
if len(permissions) > 50 {
result.AddError("permissions", "Too many permissions (max 50)", fmt.Sprintf("%d", len(permissions)))
return result
}
seen := make(map[string]bool)
for i, permission := range permissions {
field := fmt.Sprintf("permissions[%d]", i)
// Check for duplicates
if seen[permission] {
result.AddError(field, "Duplicate permission", permission)
continue
}
seen[permission] = true
// Validate individual permission
if err := v.validateSinglePermission(permission); err != nil {
result.AddError(field, err.Error(), permission)
}
}
return result
}
// ValidateTokenPrefix validates token prefixes
func (v *Validator) ValidateTokenPrefix(prefix string) *ValidationResult {
result := &ValidationResult{Valid: true}
if prefix == "" {
// Empty prefix is allowed - will use default
return result
}
if len(prefix) < 2 || len(prefix) > 4 {
result.AddError("token_prefix", "Token prefix must be between 2 and 4 characters", prefix)
return result
}
if !tokenPrefixRegex.MatchString(prefix) {
result.AddError("token_prefix", "Token prefix must contain only uppercase letters", prefix)
return result
}
return result
}
// ValidateString validates a general string with length and content constraints
func (v *Validator) ValidateString(value, fieldName string, minLen, maxLen int, allowEmpty bool) *ValidationResult {
result := &ValidationResult{Valid: true}
if value == "" && !allowEmpty {
result.AddError(fieldName, fmt.Sprintf("%s is required", fieldName), "")
return result
}
if len(value) < minLen {
result.AddError(fieldName, fmt.Sprintf("%s must be at least %d characters", fieldName, minLen), value)
return result
}
if len(value) > maxLen {
result.AddError(fieldName, fmt.Sprintf("%s must be at most %d characters", fieldName, maxLen), value)
return result
}
// Check for control characters and other potentially dangerous characters
for i, r := range value {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
result.AddError(fieldName, fmt.Sprintf("%s contains invalid control character at position %d", fieldName, i), value)
return result
}
}
// Check for null bytes
if strings.Contains(value, "\x00") {
result.AddError(fieldName, fmt.Sprintf("%s contains null bytes", fieldName), value)
return result
}
return result
}
// ValidateDuration validates duration strings
func (v *Validator) ValidateDuration(duration, fieldName string) *ValidationResult {
result := &ValidationResult{Valid: true}
if duration == "" {
result.AddError(fieldName, "Duration is required", "")
return result
}
// Basic duration format validation (Go duration format)
durationRegex := regexp.MustCompile(`^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$`)
if !durationRegex.MatchString(duration) {
result.AddError(fieldName, "Invalid duration format (use Go duration format like '1h', '30m', '5s')", duration)
return result
}
return result
}
// Helper methods
func (v *Validator) validateSinglePermission(permission string) error {
if permission == "" {
return fmt.Errorf("permission cannot be empty")
}
if len(permission) > 100 {
return fmt.Errorf("permission too long (max 100 characters)")
}
if !permissionRegex.MatchString(permission) {
return fmt.Errorf("permission must start and end with alphanumeric characters and contain only letters, numbers, dots, and underscores")
}
// Validate permission hierarchy (dots separate levels)
parts := strings.Split(permission, ".")
for i, part := range parts {
if part == "" {
return fmt.Errorf("permission level %d is empty", i+1)
}
if len(part) > 50 {
return fmt.Errorf("permission level %d is too long (max 50 characters)", i+1)
}
}
if len(parts) > 5 {
return fmt.Errorf("permission hierarchy too deep (max 5 levels)")
}
return nil
}
func (v *Validator) isPrivateOrLocalhost(host string) bool {
// Remove port if present
if colonIndex := strings.LastIndex(host, ":"); colonIndex != -1 {
host = host[:colonIndex]
}
// Check for localhost variants
localhosts := []string{"localhost", "127.0.0.1", "::1", "0.0.0.0"}
for _, localhost := range localhosts {
if strings.EqualFold(host, localhost) {
return true
}
}
// Check for private IP ranges (simplified)
privateRanges := []string{
"10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
}
for _, privateRange := range privateRanges {
if strings.HasPrefix(host, privateRange) {
return true
}
}
return false
}
// ValidateApplicationRequest validates create/update application requests
func (v *Validator) ValidateApplicationRequest(appID, appLink, callbackURL string, permissions []string) []ValidationError {
var errors []ValidationError
// Validate app ID
if result := v.ValidateAppID(appID); !result.Valid {
errors = append(errors, result.Errors...)
}
// Validate app link URL
if result := v.ValidateURL(appLink, "app_link"); !result.Valid {
errors = append(errors, result.Errors...)
}
// Validate callback URL
if result := v.ValidateURL(callbackURL, "callback_url"); !result.Valid {
errors = append(errors, result.Errors...)
}
// Validate permissions
if result := v.ValidatePermissions(permissions); !result.Valid {
errors = append(errors, result.Errors...)
}
return errors
}