Data Transfer Objects (DTOs) & Mass Assignment
In modern API development, binding incoming network payloads directly to internal domain models or database entities introduces severe architectural and security flaws. This chapter explores the mechanics of the Mass Assignment Vulnerability, analyzes why default decoding behavior in Go leaves endpoints exposed, and demonstrates how to decouple domain models using Data Transfer Objects (DTOs) for both incoming requests and outgoing responses.
1. The Hidden Trapdoor in Request Decoding
When implementing CRUD handlers, a common naive shortcut is decoding incoming JSON requests directly into the application's core entity struct—the exact struct mapped to the database schema.
Consider a standard listing creation endpoint:
At first glance, this approach appears clean, concise, and pragmatic. However, internal database entities typically include auto-generated and infrastructure-specific metadata:
- Database primary keys (
ID) - Audit timestamps (
CreatedAt,UpdatedAt) - Tenant IDs, role indicators, or internal state flags
If the decoding engine maps incoming JSON keys to all exported fields of the domain struct, the API client gains unauthorized influence over fields meant exclusively for the database and backend runtime.
2. Anatomy of the Mass Assignment Vulnerability
The Mechanism
Mass Assignment (also referred to as Over-Posting or Auto-Binding Vulnerability) occurs when an HTTP framework or JSON unmarshaler binds client-supplied parameters directly to internal data models without selective filtering.
Even if the persistence layer currently ignores the populated ID in favor of a database sequence or UUID generator, binding client input to internal fields creates an active security hazard:
- Future Regressions: A future developer might refactor the query to use an ORM or a generic
INSERT INTO listings VALUES (...)helper that consumes the entire struct, inadvertently allowing clients to hijack IDs. - Privilege Escalation: If the struct contains flags like
is_admin,verified, oraccount_balance, malicious actors can append these fields to the JSON payload and modify internal attributes.
Real-Life Analogy
The Hotel Registration Desk: Imagine arriving at a hotel and being handed a blank registration form. Instead of only filling out your name and phone number, you also write in the "Assigned Room Number" and "VIP Status" sections reserved for staff. If the hotel receptionist accepts the form without checking who filled out what, you have assigned yourself the penthouse suite for free. DTOs serve as a strict guest form that physically lacks any administrative check-boxes.
Real-World Incident: GitHub (2012)
In 2012, security researcher Egor Homakov demonstrated this exact vulnerability on GitHub. Ruby on Rails allowed mass-assigning parameters directly to ActiveRecord models. Homakov passed an unauthorized public key directly into his user record and added his key to the Rails organization itself, gaining unauthorized commit access to the core Rails repository. The incident proved that trusting client inputs against internal domain models poses an existential threat to production systems.
3. Go's Decoding Mechanics: Struct Tags & Exported Fields
To understand why this happens in Go, examine how the encoding/json package operates.
type Listing struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Price int64 `json:"price"`
City string `json:"city"`
CreatedAt time.Time `json:"created_at"`
}
The standard library decoder follows strict, deterministic rules:
- It inspects exported fields (identifiers starting with a capital letter).
- It evaluates struct tags (e.g.,
`json:"id"`). - If an incoming JSON key matches the tag (or the field name case-insensitively), the decoder assigns the incoming value to that field.
The decoder has no semantic awareness of business rules. It cannot distinguish between an input provided by an external client and a protected database primary key. If the field is exported and possesses a matching tag, the decoder will populate it.
4. Live Reproduction: Overriding Protected Fields
Consider the naive handler implementation where Listing is used as both the database model and the request decoding target:
func (h *ListingHandler) Create(w http.ResponseWriter, r *http.Request) {
var listing Listing
err := json.NewDecoder(r.Body).Decode(&listing)
if err != nil {
http.Error(w, "Invalid payload", http.StatusBadRequest)
return
}
// Logging the decoded entity reveals the injected field
fmt.Printf("Decoded Struct: %+v\n", listing)
// Database persistence logic...
}
If an attacker sends the following payload:
{
"title": "Cozy Studio",
"description": "Heart of the city",
"price": 1500,
"city": "Berlin",
"id": 9999
}
The terminal log displays:
Decoded Struct: {ID:9999 Title:Cozy Studio Description:Heart of the city Price:1500 City:Berlin CreatedAt:0001-01-01 00:00:00 +0000 UTC}
Even though the client should never supply an ID, the struct eagerly accepts 9999. The door is left open for critical security failures as the codebase grows.
5. Architectural Boundaries and the Mental Model of DTOs
The solution is structural separation: Data Transfer Objects (DTOs).
A DTO is an object designed solely to transfer data across a subsystem boundary. It carries no business logic and contains only the exact fields required for that specific network interaction.
External Network (Untrusted Client)
|
| HTTP Request (Raw JSON)
v
[ Network Boundary ]
|
| Decoded into Request DTO (`CreateListingRequest`)
v
Application Handler Layer
|
| Mapped to Internal Domain Entity (`Listing`)
v
[ Domain / Database Boundary ]
|
| SQL Execution
v
Database Storage
Boundary Isolation Principles
- Network Boundary: Data entering from the network is untrusted and must conform to an explicit inbound DTO.
- Internal Layer Boundary: Service and repository layers operate on domain entities and strongly typed interfaces.
- Outbound Boundary: Data exiting the application to clients or third-party consumers must conform to an explicit outbound DTO.
6. Designing Request DTOs: Decoupling Inputs
To isolate the input contract, define a dedicated struct within a separate file (e.g., dtos.go inside the handlers package).
package handlers
// CreateListingRequest defines the strict inbound contract for listing creation.
// Fields like ID or CreatedAt are intentionally omitted.
type CreateListingRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Price int64 `json:"price"`
City string `json:"city"`
}
Refactoring the Decoder Target
Replace the database entity with the specialized DTO in the handler:
func (h *ListingHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateListingRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
fmt.Printf("Sanitized DTO: %+v\n", req)
// Client-supplied "id" is now completely ignored by the decoder.
}
When an attacker passes "id": 9999, the decoder searches CreateListingRequest for an id tag or field. Finding none, it discards the key entirely. The internal struct is protected from tampering.
7. The Response Leak Dilemma: Go Zero-Values
The risk of structural over-exposure does not stop at incoming requests; it also impacts outbound responses.
APIs often need to return more than just an ID upon resource creation—frequently returning timestamps, generated IDs, and confirmed resource attributes. A naive developer might scan SQL output directly into the domain Listing struct and serialize it back to the client.
var out Listing
err := row.Scan(&out.ID, &out.CreatedAt)
// Remaining fields (Title, Description, Price, City) are left unpopulated!
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
The Zero-Value Side Effect
Because Go initializes unassigned struct fields to their respective zero-values ("" for strings, 0 for numbers, false for booleans), serializing the partially-populated Listing struct produces an unintended payload:
{
"id": 42,
"title": "",
"description": "",
"price": 0,
"city": "",
"created_at": "2026-03-30T10:15:00Z"
}
This response introduces significant issues:
- Misleading State: The client received confirmation of creation, but empty fields make it appear as though the title and description were wiped or lost.
- Contract Pollution: Fields that the handler never intended to return are leaked into the public contract.
- Accidental Internal Exposure: Database columns or internal flags mapped in the domain struct can inadvertently leak to the public internet.
8. Designing Response DTOs: Safe Serialization
Just as inbound requests require explicit input boundaries, outbound responses require explicit output contracts via Response DTOs.
package handlers
import "time"
// CreateListingResponse defines the exact contract returned to the client.
type CreateListingResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
}
Scanning Query Results Directly into the Response DTO
In Go, row scanning targets memory pointers. Instead of scanning into a database entity and selectively copying, you can scan database results directly into the Response DTO fields.
var resp CreateListingResponse
err := row.Scan(&resp.ID, &resp.Title, &resp.CreatedAt)
if err != nil {
http.Error(w, "Database failure", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
The resulting JSON payload is clean, accurate, and completely under developer control:
{
"id": 42,
"title": "Cozy Studio",
"created_at": "2026-03-30T10:15:00Z"
}
No zero-values are leaked, and unused fields are never serialized.
9. Architectural Comparison: Entities vs. DTOs
10. Complete Handler Refactoring Walkthrough
The following implementation combines the input DTO and output DTO patterns into a resilient, production-ready Go HTTP handler.
File: internal/handlers/dtos.go
package handlers
import "time"
// CreateListingRequest contains only allowed write fields
type CreateListingRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Price int64 `json:"price"`
City string `json:"city"`
}
// CreateListingResponse contains only allowed read fields
type CreateListingResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
}
File: internal/handlers/listings.go
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
)
type ListingHandler struct {
DB *sql.DB
}
func (h *ListingHandler) Create(w http.ResponseWriter, r *http.Request) {
// 1. Decode strictly into the Request DTO
var req CreateListingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Malformed JSON request body", http.StatusBadRequest)
return
}
// 2. Persist to Database and retrieve generated fields
query := `
INSERT INTO listings (title, description, price, city)
VALUES ($1, $2, $3, $4)
RETURNING id, title, created_at;
`
// 3. Scan directly into the Response DTO
var resp CreateListingResponse
err := h.DB.QueryRow(
query,
req.Title,
req.Description,
req.Price,
req.City,
).Scan(&resp.ID, &resp.Title, &resp.CreatedAt)
if err != nil {
http.Error(w, "Failed to create listing", http.StatusInternalServerError)
return
}
// 4. Return the isolated response contract
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
Complete End-to-End Workflow
11. Architectural Benefits: Beyond Mass Assignment
Decoupling your network interfaces from internal database representations provides multiple long-term engineering advantages:
- Renaming Freedom (Aliasing):
The external API contract can use different naming conventions than your database columns without affecting internal schema design:
GO
type CreateListingRequest struct { Name string `json:"listing_name"` // DB column remains "title" } - Independent Schema Evolution: You can migrate database columns, split tables, or introduce normalization without breaking existing external API consumers.
- Clean Validation Targets: Validation logic (e.g., checking string lengths, non-empty fields, value ranges) should run against the incoming DTO before domain logic is ever invoked.
- Zero Accidental Leaks: Password hashes, internal tenant IDs, soft-delete flags, and internal accounting details will never escape to external consumers if the response DTO explicitly excludes them.
Summary and Key Takeaways
- Mass Assignment Vulnerability: Exists when an endpoint unmarshals untrusted client payloads directly into internal models or database entities, allowing clients to manipulate unpermitted attributes.
- Standard Library Decoder Mechanics: In Go,
json.NewDecodermaps any matching JSON key to an exported struct field with a matching tag. It does not enforce permission boundaries or business intent. - DTO Mental Model: Data Transfer Objects are plain Go structs designed specifically to carry data across architectural boundaries (network to application, and application to network).
- Request DTOs Neutralize Mass Assignment: By omitting protected fields like
ID,CreatedAt, orIsAdminfrom the request struct, clients cannot populate or override them regardless of the payload sent. - Response DTOs Prevent Zero-Value Pollution: Direct serialization of partially populated database entities leaks Go zero-values (
"",0,null) and exposes internal schema structure. - Direct SQL Scan Optimization: Query results can be scanned directly into pointer targets of the Response DTO, avoiding manual boilerplate mapping between entities.
- Decoupled Evolution: DTOs isolate external API contracts from internal database migrations, allowing teams to rename fields, restructure tables, and evolve schemas independently.