Skip to content
gohacks.dev
APIs

Mass Assignment and Overposting Bugs in Go APIs and Fixes

See how decoding JSON onto a database struct lets clients set role and balance, then fix it with request types, strict decoding and allow-lists.

9 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

The most dangerous line in many Go APIs is the shortest one:

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Go
json.NewDecoder(r.Body).Decode(&user)

It looks like input handling. If user is the same struct that maps to the database, it is also a permission system with no rules, because every exported field with a JSON tag is now writable by whoever sends the request. The field you had in mind was name. The client can also send role, balance, is_verified, tenant_id or id.

This is called mass assignment (the OWASP API Security list files it under broken object property level authorisation). It is common, easy to miss and cheap to fix. This article reproduces it with a runnable example, then shows the fixes in the order I would apply them. It belongs with broken object-level authorisation as one of the two bugs that scanners rarely find and attackers always try, and it is item two in the pre-launch checklist. It ran on Go 1.25.6.

The bug

The record has fields the client must never control: Role, Balance, ID. The handler is the "update my profile" endpoint.

main.goGo
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
)

// User is the database record. Fields like Role and Balance must never be set by the client.
type User struct {
	ID      int     `json:"id"`
	Email   string  `json:"email"`
	Name    string  `json:"name"`
	Role    string  `json:"role"`
	Balance float64 `json:"balance"`
}

type db struct {
	mu    sync.Mutex
	users map[int]*User
}

func newDB() *db {
	return &db{users: map[int]*User{1: {ID: 1, Email: "[email protected]", Name: "Ada", Role: "member", Balance: 10}}}
}

// vulnerable binds the request straight onto the record.
func (d *db) vulnerable(w http.ResponseWriter, r *http.Request) {
	d.mu.Lock()
	defer d.mu.Unlock()
	u := d.users[1]
	if err := json.NewDecoder(r.Body).Decode(u); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	json.NewEncoder(w).Encode(u)
}

// UpdateProfile is the request type: the only fields a client may send, as pointers so absent differs from empty.
type UpdateProfile struct {
	Name  *string `json:"name"`
	Email *string `json:"email"`
}

func (d *db) fixed(w http.ResponseWriter, r *http.Request) {
	var in UpdateProfile
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14))
	dec.DisallowUnknownFields() // an unexpected field is a 400, so an attempt is visible in the logs
	if err := dec.Decode(&in); err != nil {
		http.Error(w, `{"error":"invalid body: `+strings.ReplaceAll(err.Error(), `"`, `'`)+`"}`, http.StatusBadRequest)
		return
	}
	d.mu.Lock()
	defer d.mu.Unlock()
	u := d.users[1]
	if in.Name != nil {
		u.Name = *in.Name
	}
	if in.Email != nil {
		u.Email = *in.Email
	}
	json.NewEncoder(w).Encode(u)
}

// patchMap is the other classic: merging a decoded map into the record.
func (d *db) patchMap(allowed map[string]bool) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var in map[string]any
		json.NewDecoder(r.Body).Decode(&in)
		d.mu.Lock()
		defer d.mu.Unlock()
		u := d.users[1]
		for k, v := range in {
			if allowed != nil && !allowed[k] {
				http.Error(w, `{"error":"field not allowed: `+k+`"}`, http.StatusUnprocessableEntity)
				return
			}
			switch k {
			case "name":
				u.Name, _ = v.(string)
			case "role":
				u.Role, _ = v.(string)
			}
		}
		json.NewEncoder(w).Encode(u)
	}
}

func main() {
	send := func(label string, h http.HandlerFunc, body string) {
		rec := httptest.NewRecorder()
		h(rec, httptest.NewRequest(http.MethodPatch, "/me", strings.NewReader(body)))
		out, _ := io.ReadAll(rec.Result().Body)
		fmt.Printf("%-34s %d %s", label, rec.Code, out)
	}
	attack := `{"name":"Mallory","role":"admin","balance":1000000,"id":2}`

	d := newDB()
	send("vulnerable, honest request", d.vulnerable, `{"name":"Ada L."}`)
	send("vulnerable, with extra fields", d.vulnerable, attack)

	d = newDB()
	send("fixed, honest request", d.fixed, `{"name":"Ada L."}`)
	send("fixed, with extra fields", d.fixed, attack)
	send("fixed, partial update keeps email", d.fixed, `{"name":"Ada"}`)

	d = newDB()
	send("map merge, no allow-list", d.patchMap(nil), `{"role":"admin"}`)
	d = newDB()
	send("map merge with an allow-list", d.patchMap(map[string]bool{"name": true}), `{"role":"admin"}`)
}
Text
vulnerable, honest request         200 {"id":1,"email":"[email protected]","name":"Ada L.","role":"member","balance":10}
vulnerable, with extra fields      200 {"id":2,"email":"[email protected]","name":"Mallory","role":"admin","balance":1000000}
fixed, honest request              200 {"id":1,"email":"[email protected]","name":"Ada L.","role":"member","balance":10}
fixed, with extra fields           400 {"error":"invalid body: json: unknown field 'role'"}
fixed, partial update keeps email  200 {"id":1,"email":"[email protected]","name":"Ada","role":"member","balance":10}
map merge, no allow-list           200 {"id":1,"email":"[email protected]","name":"Ada","role":"admin","balance":10}
map merge with an allow-list       422 {"error":"field not allowed: role"}

The first handler, vulnerable, decodes the body into the loaded user record. Run it with an honest request and everything works: the name changes to Ada L.. That is why the bug survives. Every test a developer writes sends the fields the form sends.

The second request sends the extra fields an attacker would try. The output shows the result: the user's name is Mallory, the role is admin, the balance is 1000000 and the id has become 2. One request turned an ordinary account into an admin with money. Nothing crashed, nothing was logged and the response is a 200.

The attack needs no cleverness. Attackers find the field names by reading your public API documentation, your front end's JavaScript, an error message, another endpoint's response (which probably returns the whole record, role included), or by guessing common names.

Fix 1: decode into a request type

The structural fix is to stop using one struct for two jobs. The database model describes what is stored. A request type describes what a client may send for one operation:

Go
type UpdateProfile struct {
	Name  *string `json:"name"`
	Email *string `json:"email"`
}

Now the type system enforces the allow-list. There is no field called Role to decode into, so there is nothing to overwrite. The handler copies the allowed fields onto the record explicitly. That line is the second half of the control: the copy is where you decide, with ordinary code, who can change what. If email changes need re-verification, that logic lives there too.

Make a request type per operation, even when two look similar today. CreateUser, UpdateProfile and AdminUpdateUser have different rights and will diverge.

Fix 2: reject unknown fields

By default encoding/json ignores keys it does not recognise. Your request type makes them harmless, but ignoring them hides attempts and can mask client bugs. Decoder.DisallowUnknownFields turns an unknown key into an error:

The fixed handler uses it, and the result for the attack body is a 400 with json: unknown field 'role'. The record is untouched: the decode failed before the handler changed anything. A 400 for a field the client should not have sent is also a signal worth alerting on, because honest clients do not send role. See DisallowUnknownFields for the edge cases, such as embedded structs.

Return a generic message in production if you do not want to describe your field list, though listing the unknown key is helpful to developers and the field names are rarely secret.

Fix 3: pointers for partial updates

The UpdateProfile fields are pointers so the handler can tell three states apart: field absent (nil, leave alone), field present with a value, and field present and empty. With plain string fields, a body of {"name":"Ada"} would set the email to the empty string, which is a different bug: a partial update that wipes data. The last honest request in the output shows the pointer version: only the name changes and the email survives.

For fields that can be explicitly set to null, pointers are not enough, and you need a type that records presence. Null versus omitted in Go JSON covers the options, and PATCH semantics the HTTP side.

Fix 4: allow-lists for map-based updates

Some handlers decode into map[string]any because the update is generic, then loop over the keys. That is the same bug in a different suit. The map-based handler in the lab, run with no allow-list, applied {"role":"admin"} and the role became admin. With an allow-list of name, the same body got a 422 naming the field.

Two rules for generic updates. Keep the allow-list next to the handler and make it per role: a member may change name, an admin may change role. And never use client keys as database column names. A pattern like UPDATE users SET + key + = ? turns mass assignment into SQL injection through the column name. Map allowed keys to fixed column constants.

ORMs and mass assignment

Many ORMs have helpers that make the bug one line long. GORM's Updates with a struct updates the non-zero fields, and with a map[string]any it updates every key you give it. Passing a decoded request map there is exactly the vulnerable pattern. Select and Omit can restrict the columns, and they work only if the allow-list is the code's decision and not the request's. Binding helpers in web frameworks (ShouldBindJSON in Gin, Bind in Echo) decode onto whatever struct you pass, so the rule is the same: pass a request type, never the model.

If you use sqlc, you get a form of protection by default: queries take explicit parameters, so there is no way to write "update whatever the client sent" without naming the columns.

Read-side overposting

The mirror image is leaking fields in responses. Returning the database struct with json.NewEncoder(w).Encode(user) sends the password hash, internal flags and the tenant id along with the name. Use a response type, or struct tags with json:"-" on fields that must never leave, though tags on the model are a weaker control than a separate type, since a new field is exposed by default. The same principle holds in both directions: the wire format is a deliberate contract, separate from the storage format. The response structure article and validation cover the shape and checks.

Testing for it

Add a test that sends the fields a client must not control and asserts the record did not change:

Go
func TestUpdateProfileIgnoresPrivilegedFields(t *testing.T) {
	// start with a member, send role, balance and id, expect a 400 or an unchanged record
}

Write one for each sensitive field on each writable endpoint: create and update, user and admin. A table of {field, value} pairs makes this cheap. Add a review rule too: any handler that decodes into a type from the models package is wrong by default. A lint check or a grep in CI for Decode(& followed by a model type finds most cases.

For defence in depth, protect the invariant at another layer too. A database CHECK constraint or trigger that forbids a balance change outside the payments code, and an authorisation check on role changes inside the service layer, mean one forgotten request type does not equal a breach.

A short list

  • One request type per operation, containing only editable fields.
  • Explicit copying from request to model.
  • DisallowUnknownFields, with 400 and a log line on violation.
  • Pointer fields for partial updates.
  • Allow-lists for map-based updates, per role.
  • A separate response type.
  • Tests that send privileged fields.

The OWASP API Security project describes this class as broken object property level authorization, and the encoding/json documentation covers the decoder options used above.

Also inSecurity
Taggedhardening
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.