Day 77 — Input Validation
Learning Objectives
- Build a struct tag validation framework using
reflect - Return ALL validation errors (not just the first) — critical for UX
- Enforce field-level constraints as a security layer
Key Concepts
Validation vs Sanitization:
Validation: check if input is acceptable → reject if not (CORRECT)
Sanitization: modify input to make it acceptable → risky (SQL injection sanitization often fails)
Allowlist vs Denylist:
Allowlist: only accept known-good patterns → CORRECT
Denylist: reject known-bad patterns → always incomplete
Rule: always use allowlist validation
Tags: validate:"required,min=1,max=255,pattern=^[a-zA-Z0-9_]+$"
Exercise: Struct Tag Validator
package validation
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
)
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
type ValidationErrors []ValidationError
func (ve ValidationErrors) Error() string {
msgs := make([]string, len(ve))
for i, e := range ve {
msgs[i] = e.Error()
}
return strings.Join(msgs, "; ")
}
// Validate validates a struct using `validate` field tags
// Tags: required, min=N, max=N, pattern=regex, email, oneof=a|b|c
func Validate(v interface{}) ValidationErrors {
val := reflect.ValueOf(v)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return nil
}
var errs ValidationErrors
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
fieldVal := val.Field(i)
tag := field.Tag.Get("validate")
if tag == "" { continue }
jsonName := field.Tag.Get("json")
if jsonName == "" { jsonName = field.Name }
jsonName = strings.SplitN(jsonName, ",", 2)[0]
errs = append(errs, validateField(jsonName, fieldVal, tag)...)
}
return errs
}
func validateField(name string, v reflect.Value, tag string) []ValidationError {
var errs []ValidationError
rules := strings.Split(tag, ",")
strVal := ""
if v.Kind() == reflect.String {
strVal = v.String()
}
for _, rule := range rules {
rule = strings.TrimSpace(rule)
switch {
case rule == "required":
if strVal == "" {
errs = append(errs, ValidationError{name, "required field is empty"})
}
case strings.HasPrefix(rule, "min="):
n, _ := strconv.Atoi(rule[4:])
if len(strVal) < n {
errs = append(errs, ValidationError{name, fmt.Sprintf("minimum length %d, got %d", n, len(strVal))})
}
case strings.HasPrefix(rule, "max="):
n, _ := strconv.Atoi(rule[4:])
if len(strVal) > n {
errs = append(errs, ValidationError{name, fmt.Sprintf("maximum length %d, got %d", n, len(strVal))})
}
case strings.HasPrefix(rule, "pattern="):
pattern := rule[8:]
re, err := regexp.Compile("^(?:" + pattern + ")$")
if err != nil { continue }
if !re.MatchString(strVal) {
errs = append(errs, ValidationError{name, fmt.Sprintf("invalid format (expected pattern %s)", pattern)})
}
case rule == "email":
emailRe := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
if !emailRe.MatchString(strVal) {
errs = append(errs, ValidationError{name, "invalid email address"})
}
case strings.HasPrefix(rule, "oneof="):
options := strings.Split(rule[6:], "|")
found := false
for _, opt := range options {
if strVal == opt { found = true; break }
}
if !found {
errs = append(errs, ValidationError{name, fmt.Sprintf("must be one of: %s", strings.Join(options, ", "))})
}
}
}
return errs
}
// Example usage:
type CreateUserRequest struct {
Username string `json:"username" validate:"required,min=3,max=64,pattern=[a-zA-Z0-9_]+"`
Email string `json:"email" validate:"required,email"`
Role string `json:"role" validate:"required,oneof=viewer|editor|admin"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
func ValidateHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", 400)
return
}
if errs := Validate(req); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]any{
"error": "validation failed",
"fields": errs,
})
return
}
// ... handle valid request
}
Real-World Context
Input validation is OWASP A03 (Injection prevention). Every field that reaches a database, file system, or external API must be validated. The allowlist pattern ([a-zA-Z0-9_]+) is safer than a denylist ([^<>'";&]). Returning all errors at once (not just first) reduces round-trips for API consumers.