Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix mass assignment in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Decode the body into an input struct that declares only the fields users may edit (here ProfileUpdate with DisplayName and Bio), never into the stored struct, call DisallowUnknownFields, and copy each field across yourself. encoding/json matches keys to fields case-insensitively, so a blocklist that refuses "role" by its exact name lets "Role" through.

Stack
  • Language Go
  • Runtime Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
  • Framework Go standard library 1.27.2 (net/http, encoding/json)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 21 KB
On this page

Steps

  1. Find every json.Unmarshal, Decoder.Decode and binding helper whose target is a stored type: a database model, an ORM struct or a map that gets merged into a record. Decoding into an existing struct is the usual PATCH shortcut, and it writes every field the body names.
  2. Declare one input struct per action with only the fields that caller may change, and use pointer fields to tell an absent field from an empty one.
  3. Decode with json.NewDecoder and DisallowUnknownFields, so any other key, in any letter case, is a 400 instead of being silently ignored, and cap the body with http.MaxBytesReader.
  4. Copy the input fields onto the stored record one assignment at a time. Set role, tenant and other server-owned fields only in server-side code, such as an admin endpoint with its own authorization check.
  5. Add regression tests that send role, tenantId and their case variants in an ordinary update and read the stored record back.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.goGo
func (Vulnerable) Update(user *User, body []byte) error {
	if err := json.Unmarshal(body, user); err != nil {
		return invalid(err)
	}
	return nil
}

With IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).

The fix

Fixedimpl/fixed.goGo
// ProfileUpdate holds the only fields a user may change on their own profile. Role and
// TenantID are not here: only server-side code sets them. Pointers tell "absent" from "empty".
type ProfileUpdate struct {
	DisplayName *string `json:"displayName"`
	Bio         *string `json:"bio"`
}

func (Fixed) Update(user *User, body []byte) error {
	dec := json.NewDecoder(bytes.NewReader(body))
	// Any other key, in any letter case, is a 400 instead of being ignored, so probing shows.
	dec.DisallowUnknownFields()
	var in ProfileUpdate
	if err := dec.Decode(&in); err != nil {
		return invalid(err)
	}
	if _, err := dec.Token(); !errors.Is(err, io.EOF) {
		return invalid(errors.New("unexpected data after the JSON object"))
	}
	if in.DisplayName != nil {
		user.DisplayName = *in.DisplayName
	}
	if in.Bio != nil {
		user.Bio = *in.Bio
	}
	return nil
}

With IMPL=fixed, 6 of 6 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.goGo
var serverOwnedFields = []string{"role", "tenantId"}

func (Wrong) Update(user *User, body []byte) error {
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(body, &fields); err != nil {
		return invalid(err)
	}
	for _, name := range serverOwnedFields {
		if _, found := fields[name]; found {
			return invalid(fmt.Errorf("%s cannot be changed", name))
		}
	}
	if err := json.Unmarshal(body, user); err != nil {
		return invalid(err)
	}
	return nil
}

With IMPL=wrong, 1 of 6 tests fail (1 exploit test).

Routes: PATCH /me edits the signed-in user

app.goGo
// PATCH /me always edits the signed-in user: the ID comes from authentication, never from
// the body or the URL. The implementation decides which fields of the body it applies.
mux.HandleFunc("PATCH /me", func(w http.ResponseWriter, r *http.Request) {
	id, ok := authenticate(r)
	if !ok {
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "sign in first"})
		return
	}
	body, err := readBody(w, r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "the body must be at most 64 KiB"})
		return
	}
	users.mu.Lock()
	defer users.mu.Unlock()
	user, found := users.byID[id]
	if !found {
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "sign in first"})
		return
	}
	switch err := profile.Update(&user, body); {
	case errors.Is(err, impl.ErrInvalidBody):
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid profile update"})
	case err != nil:
		log.Error("profile update failed", "user", id, "err", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
	default:
		users.byID[id] = user
		writeJSON(w, http.StatusOK, user)
	}
})

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testapp_test.goGo
// Exploit: a signed-in member sends extra fields in an ordinary profile update.

// expectServerFieldsUnchanged lets the server ignore the extra fields (200) or refuse the
// request (400). Either way the stored record keeps the role and tenant the server gave it.
// (A Go struct has no other fields to gain, unlike a map or a document.)
func expectServerFieldsUnchanged(t *testing.T, res response, users *Users) {
	t.Helper()
	if res.status != http.StatusOK && res.status != http.StatusBadRequest {
		t.Errorf("expected 200 or 400, got %d: %s", res.status, res.body)
	}
	got := stored(t, users)
	if got.Role != "member" {
		t.Errorf("role changed: %+v", got)
	}
	if got.TenantID != "red" {
		t.Errorf("tenantId changed: %+v", got)
	}
}

func TestExploitRoleInTheBodyDoesNotMakeAMemberAnAdmin(t *testing.T) {
	srv, users := newServer(t)
	expectServerFieldsUnchanged(t, patchMe(t, srv, "alice", `{"displayName": "Alice Martin", "role": "admin"}`), users)
}

func TestExploitTenantIDInTheBodyDoesNotMoveTheUserToAnotherTenant(t *testing.T) {
	srv, users := newServer(t)
	expectServerFieldsUnchanged(t, patchMe(t, srv, "alice", `{"displayName": "Alice Martin", "tenantId": "blue"}`), users)
}

func TestExploitCaseVariantsRoleAndTenantIDDoNotChangeTheRecordEither(t *testing.T) {
	srv, users := newServer(t)
	expectServerFieldsUnchanged(t, patchMe(t, srv, "alice", `{"displayName": "Alice Martin", "Role": "admin", "TenantId": "blue"}`), users)
}

Pitfalls

  • encoding/json matches object keys to struct fields case-insensitively, preferring an exact match. This card's wrong implementation refuses role and tenantId by exact name, which looks fixed when a retest sends those names, and Role and TenantId still land on the struct.
  • json.Unmarshal(body, &user) on the stored struct overwrites only the fields the body names, which is why it is the common PATCH shortcut; it is also what makes every exported field writable, including fields added later.
  • Tagging Role with json:"-" stops decoding into it but also drops it from every response and every other use of the struct. A separate input type does one job.
  • encoding/json accepts duplicate names (later values replace earlier ones) and ignores unknown keys unless you call DisallowUnknownFields. encoding/json/v2 (standard from Go 1.27; behind GOEXPERIMENT=jsonv2 in Go 1.25 and 1.26) matches names case-sensitively and rejects duplicate names by default, but it also ignores unknown names unless you set RejectUnknownMembers. Go 1.27 runs encoding/json on the v2 implementation and keeps the v1 behaviour.
  • With encoding/json/v2 this wrong fix would pass its case-variant test, because Role would no longer match role, yet the blocklist would still stop only the names on its list. The input struct does not depend on either behaviour.
  • Copying with reflection or a mapping library that matches field names is only as safe as its source type: copy from the input struct, never from a decoded entity or a map.
  • Hidden form fields and read-only inputs are not protection: any client can send any JSON it likes.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs mass-assignment/go-nethttp does this for you, in Docker if you do not have Go).
  2. Against your own API, send an ordinary profile update plus "role": "admin", then plus "Role": "admin", and read the stored record back; the role must not change.
  3. Search the code for json.Unmarshal and Decode calls whose target is a stored type; Semgrep's packs, gosec and CodeQL found nothing on any implementation here.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix mass assignment in Go and net/http
ToolRuleWhat it found here
Semgrepp/golangNo findings in either pack, on any implementation. semgrep-rules (develop at 9bbf021) has no Go rule tagged CWE-915.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config p/golang .
CodeQLgo-security-extendedNo results. github/codeql main at b5b8165 has no Go query tagged CWE-915.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image
gosecdefault rule setgosec 2.29.0No findings. gosec has no rule for decoding request data into a stored type.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./...

No Go scanner rule looks for mass assignment; the regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo standard library 1.27.2 (net/http, encoding/json)
gotest.tools/gotestsum (JUnit report, a go.mod tool)1.13.0
golang.org/x/text (raised for gotestsum)0.42.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the mass-assignment/go-nethttpfolder.

Shell
go mod download
go build ./... tool
Shell
# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go mod download && go build ./... tool'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=fixed \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=vulnerable \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=wrong \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs mass-assignment/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 21 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
Toolchain
  • go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
  • gotestsum 1.13.0
  • semgrep 1.180.0
  • gosec 2.29.0
  • govulncheck 1.8.0
  • codeql CLI 2.27.1, codeql/go-queries 1.6.12
Matrix
fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 1 of 6 tests fail (1 exploit test)
Test results
fixed 6/6 pass; vulnerable 3/6 fail (all 3 exploit tests); wrong 1/6 fail (the case-variant exploit test: the exact-name check answers 400 to role and tenantId, and encoding/json's case-insensitive matching sets Role and TenantId), with --network=none after install; the same on Go 1.26.9
Dependency freshness
Only the test runner is a module: gotestsum 1.13.0 (2025-09-11), with golang.org/x/text raised to 0.42.0 (2026-09-08) for GO-2026-5970, which also selects golang.org/x/sys 0.47.0 (2026-06-30) and golang.org/x/tools 0.49.0 (2026-08-13). Go 1.27.2 (2026-10-08) is under seven days old but is a security release. govulncheck 1.8.0 found no vulnerabilities in the module or the standard library.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Mass Assignment Cheat Sheet
  2. OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
  3. Go standard library: encoding/json Unmarshal (case-insensitive key matching)
  4. Go standard library: encoding/json Decoder.DisallowUnknownFields
  5. Go standard library: encoding/json, migrating to v2 (behaviour differences)
  6. Go standard library: encoding/json/v2 security considerations
  7. Go 1.27 release notes (encoding/json/v2)
  8. Go standard library: net/http MaxBytesReader
  9. CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.