CWE-502: Deserialization of Untrusted Data - Go
Overview
Insecure deserialization vulnerabilities in Go applications occur when untrusted data is deserialized without proper validation, allowing attackers to manipulate object state or cause denial of service. Unlike Java or Python, where deserialization gadget chains enable remote code execution, Go's standard serialization formats (encoding/json, encoding/xml, encoding/gob) have more limited attack surfaces due to Go's type system and lack of reflection-based method invocation during deserialization.
However, Go applications remain vulnerable to insecure deserialization in several ways. The encoding/gob package is the one to keep away from untrusted input: it decodes straight into your structs and sets whatever fields the payload names, and for an interface field it constructs any type your own code registered with gob.Register(). It cannot instantiate a type nothing registered - that is where it differs from Java's ObjectInputStream - but a payload choosing among your registered types, with field values of its own, is a smaller version of the same problem. JSON and XML deserialization can lead to type confusion, integer overflow, excessive memory allocation, or logic flaws when unmarshaling into interface{} types or when application logic makes assumptions about deserialized data structure.
Third-party serialization libraries (MessagePack, Protocol Buffers, YAML) introduce their own considerations, but not the one most readers expect. Go's YAML libraries have no equivalent of PyYAML's !!python/object/apply tag: measured on gopkg.in/yaml.v2 v2.4.0 and gopkg.in/yaml.v3 v3.0.1, a document carrying a Python object tag is an unmarshal error, not code execution. What remains is the same risk JSON has - blindly trusting deserialized data without validation enables privilege escalation (manipulating role fields), authentication bypass (forging session data), or business logic exploitation (modifying price/quantity fields).
Primary Defence: Avoid deserializing untrusted data entirely when possible. Use JSON for data interchange with strict schema validation. Never use encoding/gob with untrusted input. Validate all deserialized data before use, checking types, ranges, and business logic constraints, and reject unknown fields explicitly - neither encoding/json nor the YAML packages do that by default.
Common Vulnerable Patterns
gob Deserialization from User Input
// VULNERABLE - gob with untrusted data
package main
import (
"encoding/gob"
"net/http"
)
type User struct {
Username string
IsAdmin bool
Balance int64
}
func deserializeHandler(w http.ResponseWriter, r *http.Request) {
// DANGEROUS: gob.Decoder on untrusted input
decoder := gob.NewDecoder(r.Body)
var user User
err := decoder.Decode(&user)
if err != nil {
http.Error(w, "Decode failed", 400)
return
}
// VULNERABLE - Attacker controls IsAdmin field
if user.IsAdmin {
// Grant admin privileges - privilege escalation!
grantAdminAccess(user.Username)
}
// VULNERABLE - Attacker controls Balance
creditAccount(user.Username, user.Balance)
}
// ATTACK: Attacker sends gob-encoded User{Username: "attacker", IsAdmin: true, Balance: 1000000}
// Result: Privilege escalation and arbitrary balance manipulation
Why this is vulnerable: encoding/gob deserializes binary data into Go structs, but provides no protection against field manipulation. An attacker can craft gob-encoded data with IsAdmin: true and arbitrary Balance values. The application blindly trusts the deserialized data, granting admin privileges and crediting money without server-side verification. Gob is designed for trusted communication between Go programs, not for processing untrusted user input.
JSON Deserialization into interface{}
// VULNERABLE - JSON into interface{} without validation
import (
"encoding/json"
"net/http"
)
func processJSONHandler(w http.ResponseWriter, r *http.Request) {
var data map[string]interface{}
// Deserialize user input
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", 400)
return
}
// VULNERABLE - Type assertion without checking
userID := data["user_id"].(string)
role := data["role"].(string)
// DANGEROUS: Trusting deserialized role
if role == "admin" {
grantAdminPrivileges(userID)
}
// VULNERABLE - Type confusion attack
amount := data["amount"].(float64)
processPayment(userID, int64(amount))
}
// ATTACK 1: {"user_id": "attacker", "role": "admin"}
// Result: Privilege escalation
//
// ATTACK 2: {"user_id": "victim", "amount": 1.7976931348623157e+308}
// Result: Integer overflow when converting float64 to int64
//
// ATTACK 3: {"user_id": "attacker", "amount": "not a number"}
// Result: Panic from type assertion failure
Why this is vulnerable: Deserializing into interface{} types removes type safety. Attackers can provide unexpected types (string instead of number) causing panics, or exploit type conversion issues (extremely large floats overflowing to int64). The application trusts user-controlled "role" field without server-side authorization checks, enabling privilege escalation. Type assertions like .(string) panic on type mismatch, potentially causing denial of service.
YAML Deserialization
// VULNERABLE - untrusted YAML decoded straight onto a privileged config struct
import (
"io"
"net/http"
"gopkg.in/yaml.v3"
)
type Config struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
DebugMode bool `yaml:"debug_mode"`
AdminAPI bool `yaml:"admin_api"`
}
func uploadConfigHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var config Config
// DANGEROUS: every exported field is settable from the uploaded document,
// and unknown fields are silently discarded rather than rejected
err := yaml.Unmarshal(body, &config)
if err != nil {
http.Error(w, "Invalid YAML", 400)
return
}
// Apply configuration
applyConfig(config)
}
// ATTACK: admin_api: true
// Result: the uploader turns on the privileged API surface
Why this is vulnerable: The weakness here is field control, not code execution. yaml.Unmarshal populates every exported field of Config from the uploaded document, including AdminAPI and DebugMode, which the uploader has no business setting. It is also the permissive form of the decoder: unknown keys are dropped without error, so a misspelled field fails silently and a probing client gets no signal either way. Use a yaml.Decoder with KnownFields(true) (or yaml.UnmarshalStrict on v2), decode into a struct scoped to the fields a client may send, and set the privileged ones server-side.
What this pattern is not is a Go version of PyYAML's yaml.load. Measured on gopkg.in/yaml.v2 v2.4.0 and gopkg.in/yaml.v3 v3.0.1, a !!python/object/apply:os.system tag produces cannot unmarshal ... into main.Config - there is no tag that constructs an arbitrary Go type. The classic YAML denial-of-service payloads are also handled by the libraries themselves: an alias bomb is refused with document contains excessive aliasing and deep nesting with exceeded max depth of 10000. A custom UnmarshalYAML method is the one place a YAML document can reach code you wrote, and it can do only what that method does.
Session/Cookie Deserialization
// VULNERABLE - Deserializing session data without validation
import (
"encoding/base64"
"encoding/json"
"net/http"
)
type Session struct {
UserID string
Username string
IsAdmin bool
Expiry int64
}
func getSession(r *http.Request) (*Session, error) {
cookie, err := r.Cookie("session")
if err != nil {
return nil, err
}
// Decode base64
data, err := base64.StdEncoding.DecodeString(cookie.Value)
if err != nil {
return nil, err
}
// VULNERABLE - Deserialize without signature verification
var session Session
if err := json.Unmarshal(data, &session); err != nil {
return nil, err
}
// DANGEROUS: Trust deserialized IsAdmin field
return &session, nil
}
func adminHandler(w http.ResponseWriter, r *http.Request) {
session, _ := getSession(r)
// VULNERABLE - Client-controlled IsAdmin value
if session.IsAdmin {
// Attacker granted admin access!
w.Write([]byte("Admin panel"))
}
}
// ATTACK: Attacker modifies cookie, sets IsAdmin=true, base64 encodes
// Result: Complete authentication bypass
Why this is vulnerable: Base64 encoding is not encryption - it provides no integrity protection. Attackers can decode the session cookie, modify the JSON (changing IsAdmin to true), re-encode it, and send it back. The application blindly trusts the deserialized session data without verifying its authenticity. Session data must be either stored server-side (referenced by a random token) or cryptographically signed/encrypted to prevent tampering.
Secure Patterns
Use JSON with Strong Type Validation
// SECURE - JSON with strict type checking and validation
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
)
type UserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Age int `json:"age"`
}
func (ur *UserRequest) Validate() error {
// Validate username
if len(ur.Username) < 3 || len(ur.Username) > 32 {
return errors.New("username must be 3-32 characters")
}
// Validate email format
if !isValidEmail(ur.Email) {
return errors.New("invalid email format")
}
// Validate age range
if ur.Age < 0 || ur.Age > 150 {
return errors.New("age must be between 0 and 150")
}
return nil
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
// Limit request size
r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB
var userReq UserRequest
// SECURE - Decode into strongly-typed struct
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // Reject unexpected fields
if err := decoder.Decode(&userReq); err != nil {
http.Error(w, "Invalid request format", http.StatusBadRequest)
return
}
// SECURE - Validate all fields before use
if err := userReq.Validate(); err != nil {
http.Error(w, fmt.Sprintf("Validation failed: %v", err), http.StatusBadRequest)
return
}
// Server-side authorization - never trust client
// IsAdmin determined by backend logic, not user input
isAdmin := checkAdminPermissions(r.Context())
// Create user with validated data
user := createUser(userReq.Username, userReq.Email, userReq.Age, isAdmin)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "created",
"user": user,
})
}
func isValidEmail(email string) bool {
// Simplified - use a proper email validation library such as
// github.com/go-playground/validator/v10 with the `email` tag
return len(email) > 3 && strings.Contains(email, "@")
}
Why this works: Deserializing into strongly-typed structs eliminates many type confusion attacks - the JSON decoder rejects mismatched types. DisallowUnknownFields() prevents attackers from injecting unexpected fields that might be processed elsewhere. The Validate() method checks all constraints (length, format, range) before the data is used. Critically, security-sensitive fields like IsAdmin are determined server-side through checkAdminPermissions(), never from user input. http.MaxBytesReader prevents resource exhaustion from extremely large payloads.
Signed Session Tokens with HMAC
// SECURE - Cryptographically signed session data
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"time"
)
// The platform injects the key at process start; it is held in a secrets
// manager, not in source and not in a checked-in .env file - see CWE-526.
// Failing at start-up beats starting with an empty key that signs everything.
var sessionSecret = mustSessionSecret()
func mustSessionSecret() []byte {
key, err := base64.StdEncoding.DecodeString(os.Getenv("SESSION_SIGNING_KEY"))
if err != nil || len(key) < 32 {
panic("SESSION_SIGNING_KEY must be at least 32 base64-encoded bytes")
}
return key
}
type SessionData struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Expiry int64 `json:"expiry"`
}
type SignedSession struct {
Data string `json:"data"`
Signature string `json:"signature"`
}
func createSignedSession(userID, username string) (string, error) {
// Create session data
session := SessionData{
UserID: userID,
Username: username,
Expiry: time.Now().Add(24 * time.Hour).Unix(),
}
// Serialize to JSON
sessionJSON, err := json.Marshal(session)
if err != nil {
return "", err
}
// Base64 encode
dataB64 := base64.StdEncoding.EncodeToString(sessionJSON)
// SECURE - Generate HMAC signature
mac := hmac.New(sha256.New, sessionSecret)
mac.Write([]byte(dataB64))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Combine data and signature
signed := SignedSession{
Data: dataB64,
Signature: signature,
}
signedJSON, err := json.Marshal(signed)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(signedJSON), nil
}
func verifyAndDecodeSession(cookieValue string) (*SessionData, error) {
// Decode outer base64
signedJSON, err := base64.StdEncoding.DecodeString(cookieValue)
if err != nil {
return nil, errors.New("invalid session format")
}
// Unmarshal signed session
var signed SignedSession
if err := json.Unmarshal(signedJSON, &signed); err != nil {
return nil, errors.New("invalid session structure")
}
// SECURE - Verify HMAC signature
mac := hmac.New(sha256.New, sessionSecret)
mac.Write([]byte(signed.Data))
expectedMAC := mac.Sum(nil)
providedMAC, err := base64.StdEncoding.DecodeString(signed.Signature)
if err != nil {
return nil, errors.New("invalid signature format")
}
if !hmac.Equal(expectedMAC, providedMAC) {
return nil, errors.New("signature verification failed")
}
// Decode data
sessionJSON, err := base64.StdEncoding.DecodeString(signed.Data)
if err != nil {
return nil, errors.New("invalid session data")
}
// Unmarshal session data
var session SessionData
if err := json.Unmarshal(sessionJSON, &session); err != nil {
return nil, errors.New("invalid session content")
}
// SECURE - Verify expiry
if time.Now().Unix() > session.Expiry {
return nil, errors.New("session expired")
}
return &session, nil
}
func protectedHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Error(w, "Not authenticated", http.StatusUnauthorized)
return
}
session, err := verifyAndDecodeSession(cookie.Value)
if err != nil {
http.Error(w, "Invalid session", http.StatusUnauthorized)
return
}
// Use verified session data
fmt.Fprintf(w, "Welcome, %s!", session.Username)
}
Why this works: HMAC-SHA256 signatures ensure session data integrity - any tampering invalidates the signature. The signature is verified using constant-time comparison (hmac.Equal) to prevent timing attacks. Even if attackers decode and modify the session data, they cannot forge a valid signature without the secret key. Expiry checks prevent replay attacks with old sessions. The secret key must come from a secrets manager, or be injected into the process environment at start-up rather than kept there (CWE-526), and rotated periodically. This approach allows stateless sessions while preventing tampering.
Avoid gob - Use JSON for Data Exchange
// SECURE - Use JSON instead of gob for untrusted data
import (
"encoding/json"
"net/http"
)
type SafeUserData struct {
Username string `json:"username"`
Email string `json:"email"`
}
func safeDeserializeHandler(w http.ResponseWriter, r *http.Request) {
var userData SafeUserData
// SECURE - JSON with type safety
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1048576))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&userData); err != nil {
http.Error(w, "Invalid input", http.StatusBadRequest)
return
}
// Validate
if len(userData.Username) == 0 || len(userData.Email) == 0 {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// Authorization determined server-side, NEVER from user input
isAdmin := checkUserRole(r.Context(), userData.Username)
processUser(userData.Username, userData.Email, isAdmin)
w.WriteHeader(http.StatusOK)
}
Why this works: JSON is simpler and safer than gob for untrusted input. It doesn't support arbitrary type instantiation or complex object graphs that could be exploited. The strongly-typed SafeUserData struct ensures only expected fields are accepted. DisallowUnknownFields() rejects payloads with extra fields an attacker might inject hoping they'll be processed elsewhere. Security-critical decisions (admin status) are made server-side based on database lookups or authentication context, never from deserialized user data.
Server-Side Session Storage
// SECURE - Server-side session storage with random tokens
import (
"crypto/rand"
"encoding/base64"
"errors"
"io"
"net/http"
"sync"
"time"
)
type ServerSession struct {
UserID string
Username string
IsAdmin bool
CreatedAt time.Time
}
var (
sessionStore = make(map[string]*ServerSession)
sessionMutex sync.RWMutex
)
func generateSessionToken() (string, error) {
// SECURE - Cryptographically random token
bytes := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
func createSession(userID, username string, isAdmin bool) (string, error) {
token, err := generateSessionToken()
if err != nil {
return "", err
}
session := &ServerSession{
UserID: userID,
Username: username,
IsAdmin: isAdmin,
CreatedAt: time.Now(),
}
sessionMutex.Lock()
sessionStore[token] = session
sessionMutex.Unlock()
return token, nil
}
func getSessionByToken(token string) (*ServerSession, error) {
sessionMutex.RLock()
session, exists := sessionStore[token]
sessionMutex.RUnlock()
if !exists {
return nil, errors.New("session not found")
}
// Check expiry
if time.Since(session.CreatedAt) > 24*time.Hour {
// Clean up expired session
sessionMutex.Lock()
delete(sessionStore, token)
sessionMutex.Unlock()
return nil, errors.New("session expired")
}
return session, nil
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
// Authenticate user
userID, isAdmin, err := authenticateUser(username, password)
if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Create server-side session
token, err := createSession(userID, username, isAdmin)
if err != nil {
http.Error(w, "Session creation failed", http.StatusInternalServerError)
return
}
// Set cookie with random token only
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: token,
Path: "/",
MaxAge: 86400, // 24 hours
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
w.WriteHeader(http.StatusOK)
}
func authenticatedHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_token")
if err != nil {
http.Error(w, "Not authenticated", http.StatusUnauthorized)
return
}
session, err := getSessionByToken(cookie.Value)
if err != nil {
http.Error(w, "Invalid session", http.StatusUnauthorized)
return
}
// SECURE - Session data comes from server, not client
if session.IsAdmin {
w.Write([]byte("Admin panel"))
} else {
w.Write([]byte("User dashboard"))
}
}
func authenticateUser(username, password string) (string, bool, error) {
// Database lookup, password verification
// Returns userID, isAdmin status, error
return "", false, nil
}
Why this works: Session data never leaves the server, eliminating deserialization risks entirely. The client only receives a cryptographically random token that cannot be predicted or manipulated. All session data (including IsAdmin status) is stored server-side, making it impossible for attackers to tamper with. Token-to-session lookup is protected by mutex for thread safety. Expired sessions are automatically cleaned up. For production, replace the in-memory map with Redis, Memcached, or a database for distributed systems and persistence.
Schema Validation with JSON Schema
// SECURE - JSON Schema validation for complex data
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/xeipuuv/gojsonschema"
)
var orderSchema = `{
"type": "object",
"required": ["customer_id", "items", "total"],
"properties": {
"customer_id": {
"type": "string",
"minLength": 1,
"maxLength": 50
},
"items": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"type": "object",
"required": ["product_id", "quantity", "price"],
"properties": {
"product_id": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1, "maximum": 1000},
"price": {"type": "number", "minimum": 0}
}
}
},
"total": {
"type": "number",
"minimum": 0
}
},
"additionalProperties": false
}`
func validateOrder(orderJSON []byte) error {
schemaLoader := gojsonschema.NewStringLoader(orderSchema)
documentLoader := gojsonschema.NewBytesLoader(orderJSON)
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
if err != nil {
return err
}
if !result.Valid() {
// Collect all validation errors
var errMsg string
for _, err := range result.Errors() {
errMsg += err.String() + "; "
}
return errors.New(errMsg)
}
return nil
}
func submitOrderHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1048576))
if err != nil {
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
return
}
// SECURE - Validate against schema before deserializing
if err := validateOrder(body); err != nil {
http.Error(w, "Invalid order format: "+err.Error(), http.StatusBadRequest)
return
}
// Deserialize validated data
var order map[string]interface{}
json.Unmarshal(body, &order)
// Additional business logic validation
if !verifyOrderTotal(order) {
http.Error(w, "Order total mismatch", http.StatusBadRequest)
return
}
// Process order
processOrder(order)
w.WriteHeader(http.StatusCreated)
}
func verifyOrderTotal(order map[string]interface{}) bool {
// Recalculate total server-side, don't trust client
items := order["items"].([]interface{})
var calculatedTotal float64
for _, item := range items {
itemMap := item.(map[string]interface{})
quantity := itemMap["quantity"].(float64)
price := itemMap["price"].(float64)
calculatedTotal += quantity * price
}
providedTotal := order["total"].(float64)
// Allow small floating point differences
return abs(calculatedTotal-providedTotal) < 0.01
}
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
Why this works: The schema is checked before the payload is unmarshaled into application types, so types, required fields, array lengths, number ranges and string lengths are all enforced on data that has not yet reached any handler logic. Business logic validation (verifyOrderTotal) then recalculates the total server-side rather than trusting the client-provided value, which is what stops price manipulation.
One detail to copy carefully: "additionalProperties": false applies only to the object it appears in. In the schema above that is the top-level order, so an item inside items still accepts any extra key. Repeat "additionalProperties": false in every nested object schema, and confirm it with a payload that adds a key one level down - a schema that rejects {"order_id": ...} at the root and admits {"items":[{"product_id": ..., "discount_override": 1}]} reads as strict and is not.
Framework-Specific Guidance
Gin with Request Binding and Validation
// SECURE - Gin with built-in validation
package main
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// Stock carries no `required`: on a numeric field that tag means "not the zero
// value", so `required,gte=0` would reject the stock=0 that `gte=0` allows.
type CreateProductRequest struct {
Name string `json:"name" binding:"required,min=3,max=100"`
Description string `json:"description" binding:"max=500"`
Price float64 `json:"price" binding:"required,gt=0,lte=1000000"`
Stock int `json:"stock" binding:"gte=0,lte=10000"`
Category string `json:"category" binding:"required,oneof=electronics clothing food"`
Vendor string `json:"vendor" binding:"required,notreserved"`
}
func main() {
r := gin.Default()
// Register a custom rule, then USE it - a rule registered and never named
// in a binding tag runs on nothing, and the struct above is the only place
// that can tell it which field to guard.
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("notreserved", func(fl validator.FieldLevel) bool {
return fl.Field().String() != "admin" && fl.Field().String() != "system"
}); err != nil {
panic(err)
}
}
r.POST("/products", createProductHandler)
r.Run(":8080")
}
func createProductHandler(c *gin.Context) {
var req CreateProductRequest
// SECURE - Gin validates based on struct tags
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Validation failed",
"details": err.Error(),
})
return
}
// Additional custom validation
if req.Price*float64(req.Stock) > 10000000 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Total inventory value exceeds limit",
})
return
}
// Server-side authorization
userID := c.GetString("user_id") // From auth middleware
if !canCreateProduct(userID) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Insufficient permissions",
})
return
}
// Create product with validated data
product := createProduct(req.Name, req.Description, req.Price, req.Stock, req.Category, req.Vendor)
c.JSON(http.StatusCreated, gin.H{
"status": "created",
"product": product,
})
}
func canCreateProduct(userID string) bool {
// Database lookup for permissions
return true
}
func createProduct(name, desc string, price float64, stock int, category, vendor string) interface{} {
return map[string]interface{}{"name": name}
}
Why this works: Gin's ShouldBindJSON automatically validates based on struct field tags: required, min, max, gt (greater than), lte (less than or equal), oneof for enums. Validation happens during deserialization, rejecting invalid data before it reaches handler logic. Authorization is still performed server-side through canCreateProduct() lookup, never trusting client input for permissions.
Read required carefully before combining it with a numeric bound: it means "not the zero value", not "the key was present". binding:"required,gte=0" on an int therefore rejects "stock":0 with a 400 naming the required tag, even though gte=0 declares zero valid - a validator that refuses legitimate input, which no re-scan will report. Where zero is a real value and you also need to tell it apart from an omitted key, make the field a *int and keep required on the pointer.
Custom rules work the same way, and the registration is only half of one. RegisterValidation("notreserved", ...) teaches the engine what the name means; nothing runs until a field's binding tag names it, which is why Vendor carries binding:"required,notreserved" above. A rule registered and never referenced is dead code that reviews as a control. Confirm it fires by posting the value it is supposed to refuse: {"vendor":"admin"} returns 400, the same payload with "vendor":"acme" returns 201.
Echo with Custom Validators
// SECURE - Echo with validator integration
package main
import (
"net/http"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
return err
}
return nil
}
type UpdateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,gte=18,lte=120"`
Username string `json:"username" validate:"required,min=3,max=32,alphanum"`
}
func main() {
e := echo.New()
// Register validator
e.Validator = &CustomValidator{validator: validator.New()}
e.POST("/users/:id", updateUserHandler)
e.Start(":8080")
}
func updateUserHandler(c echo.Context) error {
userID := c.Param("id")
var req UpdateUserRequest
// Bind and validate
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request format",
})
}
// SECURE - Validate with registered validator
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Validation failed: " + err.Error(),
})
}
// Server-side authorization
currentUserID := c.Get("user_id").(string) // From JWT middleware
if currentUserID != userID && !isAdmin(currentUserID) {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Cannot update other users",
})
}
// Update user
updateUser(userID, req.Email, req.Age, req.Username)
return c.JSON(http.StatusOK, map[string]string{
"status": "updated",
})
}
func isAdmin(userID string) bool {
// Check database for admin role
return false
}
func updateUser(id, email string, age int, username string) {}
Why this works: Echo reaches go-playground/validator through the CustomValidator wrapper, which is what makes the validate: struct tags take effect. Email validation, age ranges, alphanumeric checks, and length constraints are enforced before the handler processes data. Authorization logic checks whether the current user can update the target user, preventing unauthorized modifications. Validation errors are caught early and returned with clear error messages.
Common Pitfalls
- Decoding untrusted JSON directly into a domain or persistence struct that has privileged fields such as
IsAdminorBalancewith matching JSON tags, instead of a separate request DTO.encoding/jsonhas no concept of "attacker-supplied" versus "server-computed" - any exported field with a JSON tag is settable unless the struct passed toDecode()is scoped to only the fields a client should be able to send. - Switching from
gobtoencoding/jsonwithout addingdecoder.DisallowUnknownFields(). By defaultencoding/jsonsilently ignores fields it doesn't recognize rather than erroring, which reads as safe but means a client can still probe for unexpected fields with no error signal, and a typo in an expected field name fails silently instead of loudly. - Using
gob.Register()to support legitimate polymorphism through an interface field, then decoding attacker-supplied gob data into that interface. Unlike Java'sObjectInputStream,gobcan't instantiate an arbitrary unregistered type - but an attacker can still choose which of the types your own code registered gets populated, and with what field values, which is a smaller but real version of the same problem. - Treating a switch to
gopkg.in/yaml.v2orv3as automatically safe because Go's YAML libraries don't execute arbitrary code the way Python'syaml.loadcan. That's true for RCE, but the parsed data still needs the same field-level validation as JSON - "no code execution" isn't the same as "no need to validate business-logic fields."
Testing
A scanner can see that a dangerous decoder is gone. It cannot see whether the replacement accepts only what you intended, which is the whole of the fix. Assert each of these:
- Valid input still decodes. A well-formed payload produces the expected struct with every field populated. This is the test that catches an over-tightened allowlist before users do.
- Unknown fields are rejected, not ignored. Send a payload containing a
field the struct does not declare and assert it errors.
encoding/jsonsilently discards unknown fields unless you calldecoder.DisallowUnknownFields(), so without this assertion you cannot tell which behaviour you have. - Type confusion is rejected. Send a string where a number is expected, an
object where a scalar is expected, and
nullfor a non-pointer field. Each should produce an error, not a zero value that later code treats as legitimate. - Size and depth are bounded. Send a payload larger than the intended
limit and a deeply nested one (several thousand levels of
[), and assert both are rejected before decoding completes. Deep nesting is a denial of service that a size limit alone does not stop. - Decoding does not execute anything. For any format that supports type information, assert that a payload naming an unexpected type fails to resolve rather than constructing it. The value of a data-only format is precisely that this test is easy to satisfy.
- Errors reveal nothing structural. Assert the caller-visible message is generic. Go's decode errors include field names and offsets, which is useful in a log and an inventory of your schema in an HTTP response.
Additional Resources
- CWE-502: Deserialization of Untrusted Data
- Go encoding/gob Package (avoid for untrusted input)
- Go encoding/json Package
- Gorilla Sessions (server-side session management)
- OWASP Deserialization Cheat Sheet
- go-playground/validator
- gojsonschema Library