375 lines
10 KiB
Go
375 lines
10 KiB
Go
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
|
|
} |