In short

Beyond the object check, decide which operations the caller may invoke and which fields they may write and read. Bind each request to an operation-specific type that lists only the writable fields and rejects the rest, never to the stored entity. Return an explicit response projection instead of the database record. Put privileged changes such as role or tenant in separate actions that check the actor and tenant on the server.

  • Owning a record does not make every field writable: role, tenant, owner and prices need their own permission.
  • Bind to a DTO, serializer or schema that names the writable fields; never copy the request onto the entity with Object.assign or framework auto-binding.
  • Reject unknown fields with 400 so mistaken clients fail visibly, and validate every allowed value.
  • Return a projection per audience; hiding a field in the interface does not remove it from the JSON.
  • Check function permission on the server for every admin route, including tenant scope and the current state of the target.

Verify it

  1. PATCH your own profile with role, tenant and a nested copy of them, and confirm a 400 and an unchanged stored row.
  2. Call each admin route directly as a member, as an administrator of another tenant and unauthenticated; each must be denied.
  3. Compare every response body with its documented projection and assert sensitive fields are absent, not merely empty.
  4. Add a column in a test migration and confirm it is neither writable nor returned until a contract adds it.
  5. Search for Object.assign(, fields = '__all__', permit! and request bodies typed as entities, and replace each.
Applies to

Role-sensitive endpoints and mass-assignment defenses. Field permissions apply on responses as well as writes.

Part of
Learning path: Browser and API boundaries (step 8 of 18). Workflows: Fix a finding, Secure a feature, Test an application

Fix it in your stack

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

Learning goals and prerequisitesAbout 27 minutes: 8 to read and 19 for the exercise

What you will learn

  • Separate object, function, write-field and read-field permission.
  • Construct an operation-specific input and output projection.
  • Verify atomic rejection and privileged state transitions.

Before you start

API and backend developers who bind requests to models or expose admin operations, and reviewers checking mass-assignment and data-exposure findings.

  • Explain object ownership and the difference between reading a record and editing its fields.

Refresh first: Enforce authorization for every object action

Go to the guided practice

Ask four separate permission questions

Object permission says whether Alice may access profile A. It does not decide whether she may invoke an administrator operation on it, change its role, change its tenant or read internal review notes. Those are function and property permissions. A generic endpoint that copies every submitted field into an authorized object can therefore create privilege escalation without any object-id tampering.

Use a profile with id A, tenant red, displayName Alice, locale en, role member and internalRiskReview pending. Alice may update displayName and locale on her own profile. Only an authorized tenant administrator may assign an allowed role within that tenant, through a separate action. No public profile response includes internalRiskReview. A PATCH containing displayName=Alice2 and role=admin should not silently turn into an administrator change simply because Alice owns the row.

This is how GitHub was compromised in March 2012. Its Rails application bound a public-key form to the model without restricting attributes, so a user could submit another account's user id with his own key (CVE-2012-2055) and then push a commit to the rails/rails repository. Rails soon made attribute allowlisting the default for new applications and later replaced it with strong parameters, which name the permitted fields per action.

Build writable and readable projections explicitly

A vulnerable implementation executes Object.assign(profile, request.body). A safer design parses an operation-specific input schema and constructs a new update object from the allowed fields. For this example, reject unknown fields with 400 rather than silently ignoring them, so mistaken client assumptions are visible. Validate types, lengths and locale values after the allowlist; an allowed field name does not make an arbitrary value valid. Do not let nested properties smuggle equivalent authority through an alternate path.

Use a separate response projection as well. Returning a database entity wholesale can leak internal fields added by a later migration even if today's response looks harmless. Explicitly return id, displayName and locale for the public profile. Administrators may need a different projection with an independently authorized purpose. Hiding fields in the browser does not prevent their disclosure in the network response, and marking a property read-only in client-generated documentation does not constrain the server.

The module below is the projection for the self-service PATCH, with the exercise requests as tests. It distinguishes an omitted field, which leaves the stored value unchanged, from an explicit null, which the contract rejects, and it rejects role, a nested profile object and an own __proto__ key as unknown fields. Run node profile-update.mjs; it prints PASS: 2 updates accepted; 6 forbidden or invalid bodies rejected.

Fixedprofile-update.mjsJavaScript
// profile-update.mjs (Node.js 22+): operation-specific projection, after own-profile authorization.
import assert from 'node:assert/strict';

export function selectProfileUpdate(body) {
  const allowed = new Set(['displayName', 'locale']);
  if (!body || Array.isArray(body) || typeof body !== 'object') throw new Error('invalid_input');
  if (Object.keys(body).some(key => !allowed.has(key))) throw new Error('unknown_field');
  const update = {};
  if (Object.hasOwn(body, 'displayName')) {
    const name = body.displayName;
    if (typeof name !== 'string' || name.length < 1 || name.length > 80)
      throw new Error('invalid_name');
    update.displayName = name;
  }
  if (Object.hasOwn(body, 'locale')) {
    if (!['en', 'fr'].includes(body.locale)) throw new Error('invalid_locale');
    update.locale = body.locale;
  }
  return update; // the handler writes only these fields, in one transaction
}

// Requests from the exercise; JSON.parse mirrors what a body parser produces.
const parse = json => selectProfileUpdate(JSON.parse(json));
assert.deepEqual(parse('{"displayName":"Alice2","locale":"fr"}'), {displayName: 'Alice2', locale: 'fr'});
assert.deepEqual(parse('{"locale":"fr"}'), {locale: 'fr'}); // omitted name is left unchanged
for (const [json, error] of [
  ['{"displayName":"Alice2","role":"tenant_admin"}', 'unknown_field'], // R2
  ['{"profile":{"role":"tenant_admin"}}', 'unknown_field'], // R3
  ['{"__proto__":{"role":"tenant_admin"}}', 'unknown_field'],
  ['[]', 'invalid_input'],
  ['{"locale":null}', 'invalid_locale'],
  ['{"displayName":""}', 'invalid_name'],
])
  assert.throws(() => parse(json), {message: error}, json);
console.log('PASS: 2 updates accepted; 6 forbidden or invalid bodies rejected');

Use your framework's field allowlist

Most frameworks bind requests automatically, and most default to dropping unknown fields silently or, worse, binding every field the model has. Give each operation its own input type, and turn on the setting that makes extra fields an error so tests notice them. Schema strictness alone is not field authorization: Mongoose strict mode drops paths that are not in the schema, but role is in the schema, so the handler must still pick the writable keys. The fix cards at /fix/mass-assignment/ show this allowlist in seven frameworks.

The C# program below binds the same PATCH to a record that has only DisplayName and Locale. JsonUnmappedMemberHandling.Disallow turns role and the nested profile object into errors instead of silently ignoring them, and a check before binding refuses explicit nulls, because a nullable property cannot otherwise tell null from an omitted field. It ran with dotnet run on .NET 10; without the Disallow setting, the role body is accepted with the field ignored.

Framework binding can be mass assignment one level down. In 2022 Spring's data binding let request parameters walk from a form object to the class loader on JDK 9 and later (CVE-2022-22965, Spring4Shell), and the denylist workaround leaked twice; an allowlist of bindable fields would have stopped the walk at the first step: /domains/appsec/secure-coding/case-study-spring4shell/.

Use your framework's field allowlist
FrameworkBind only the writable fieldsMake extra fields an error
Ruby on Railsparams.expect(profile: [:display_name, :locale]) in Rails 8, or require(:profile).permit(...)action_on_unpermitted_parameters = :raise
Django REST frameworkA serializer with an explicit fields list and read_only_fieldsUnknown keys are dropped silently; compare initial_data with the declared fields to reject them
Django formsModelForm with fields = ["display_name", "locale"], never "__all__"Fields outside the form are ignored
Spring MVCA request DTO per operation, never the entity; WebDataBinder.setAllowedFields for form bindingspring.jackson.deserialization.fail-on-unknown-properties=true
ASP.NET CoreA DTO per action; [Bind("DisplayName,Locale")] for MVC form postsJsonUnmappedMemberHandling.Disallow (.NET 8 and later)
Laravel$fillable on the model and $request->validated()Model::preventSilentlyDiscardingAttributes()
Express with MongoosePick the allowed keys before findOneAndUpdatestrict: 'throw' on the schema for paths it does not define
NestJSDTO classes with class-validator decoratorsValidationPipe with whitelist: true and forbidNonWhitelisted: true
FastAPI and PydanticA request model with only the writable fieldsmodel_config = ConfigDict(extra="forbid")
FixedProgram.csC#
// Program.cs (.NET 8+): bind the body to a DTO that holds only the writable fields.
using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, // unknown field: error
};

ProfileUpdate Parse(string json)
{
    using var doc = JsonDocument.Parse(json);
    if (doc.RootElement.ValueKind != JsonValueKind.Object) throw new JsonException("shape");
    foreach (var field in doc.RootElement.EnumerateObject()) // null is not "leave unchanged"
        if (field.Value.ValueKind == JsonValueKind.Null) throw new JsonException(field.Name);
    var update = doc.RootElement.Deserialize<ProfileUpdate>(options)!;
    if (update.DisplayName is { Length: < 1 or > 80 }) throw new JsonException("displayName");
    if (update.Locale is not (null or "en" or "fr")) throw new JsonException("locale");
    return update; // the handler copies these values onto the caller's own profile
}

Check(Parse("""{"displayName":"Alice2","locale":"fr"}""") == new ProfileUpdate("Alice2", "fr"), "R1");
Check(Parse("""{"locale":"fr"}""") == new ProfileUpdate(null, "fr"), "omitted name stays unchanged");
foreach (var body in new[] {
    """{"displayName":"Alice2","role":"tenant_admin"}""", // R2: privileged field
    """{"profile":{"role":"tenant_admin"}}""",             // R3: nested smuggling
    """{"locale":null}""", """{"locale":"de"}""", """{"displayName":""}""", "[]" })
{
    try { Parse(body); throw new InvalidOperationException("accepted " + body); }
    catch (JsonException) { }
}
Console.WriteLine("PASS: 2 updates bound; 6 forbidden or invalid bodies rejected");

static void Check(bool ok, string what) { if (!ok) throw new InvalidOperationException(what); }

public sealed record ProfileUpdate(string? DisplayName, string? Locale);

Bind privileged transitions to current state

Function authorization must be performed on the server for every route or RPC method. A member cannot gain a role by calling a hidden /admin endpoint directly. An administrator's role may itself be tenant-scoped, and the target user must belong to the same tenant. Define allowed transitions: promoting a user to billing_admin is different from changing the user's tenant or granting a global support role. Centralize the policy so alternate routes, GraphQL resolvers, batch APIs and background jobs cannot choose a weaker path. The fix cards at /fix/missing-authorization/ show the server-side check in each framework.

For sensitive changes, check permission consistently with the committed state. If the actor loses administrator status while a queued action waits, decide whether execution requires current authority. If two administrators race to remove the last administrator, a per-request check may pass for both unless the invariant is protected transactionally. For approvals, bind the decision to the exact version and material fields approved, so a change to payment details after approval invalidates or repeats it. These are state-transition rules beyond a field allowlist. Record actor, target, allowed operation and changed field names, while excluding secret field values from logs.

Test forbidden fields and forbidden functions independently

The test matrix should preserve Alice's ordinary name update while rejecting role and tenant modifications. Check both top-level and nested fields, null, arrays and unexpected JSON shapes. Inspect the stored row after rejection; a handler that updates displayName before discovering role must roll back or validate the entire request before writing. The exercise chooses atomic rejection of mixed valid and invalid fields.

Read tests should assert absence, not merely an empty value, of internalRiskReview in the serialized response. Function tests should call the endpoint directly as a member, as a tenant administrator, as an administrator of another tenant and without authentication. If a framework serializer or form model automatically binds fields, review its actual allowed-field configuration and migrations. Keep policy tests near the operation contract so a new database property does not accidentally become writable or public.

Separate omission, deletion and unchanged values

Partial updates need precise semantics. An omitted locale means leave the stored locale unchanged. A supplied locale=null is not the same operation unless the schema explicitly supports clearing it; the fixture rejects it. For displayName, an empty string fails the stated minimum, while an omitted field does not erase the name. A general object merge often blurs these distinctions and can turn missing or null values into unintended updates.

Consider an administrator request to assign the role already present. It may be a permitted no-op, but the route must still check administrator authority and tenant scope. Returning success before authorization because 'nothing would change' can leak sensitive current state or establish a bypass that becomes dangerous when the operation later gains side effects. Conversely, audit logging should distinguish a no-op from an actual privilege change so investigators do not infer a promotion that never occurred.

For bulk profile edits, repeat field and function authorization per operation rather than authorizing only the container request. A member may be allowed to update their display name but not another user's locale or anyone's role. If the endpoint promises atomic behavior, validate every requested change before committing any. The regression should include a mixed request with one permitted name update and one forbidden role change and require zero stored changes, preserving the same atomic rejection principle used in R2.

Guided practice

Apply a complete profile write/read policy to supplied requests.

Supplied scenario material
Plain text
Stored A={id:A,tenant:red,displayName:Alice,locale:en,role:member,internalRiskReview:pending}.
Alice=(id A,red,member). Cara=(id C,red,tenant_admin). Dan=(id D,blue,tenant_admin).
Self PATCH accepts only displayName (1..80 characters) and locale (en|fr); rejects unknown fields atomically.
Public GET returns id,displayName,locale only.
Admin role action: same-tenant tenant_admin may assign member or billing_admin.
R1 Alice PATCH {displayName:Alice2,locale:fr}.
R2 Alice PATCH {displayName:Alice2,role:tenant_admin}.
R3 Alice PATCH {profile:{role:tenant_admin}}.
R4 Alice invokes admin role action on A ->billing_admin.
R5 Cara invokes same action. R6 Dan invokes same action.
Each request starts from original state.
  1. 1. Compare assignment with projection

    Evaluate R1 and R2 under the stated policy, including final displayName and role.

    Expected result and reasoning

    R1 succeeds: Alice2, fr, member. R2 rejects the whole request with 400; displayName remains Alice and role remains member. Blind assignment would incorrectly allow role escalation.

  2. 2. Handle nested and malformed fields

    Evaluate R3, then a body that is an array or locale=null.

    Expected result and reasoning

    R3 rejects unknown profile; arrays reject input shape; null locale rejects its enum/type constraint. None mutates any stored field. Allowed top-level names alone do not validate their values.

  3. 3. Authorize the privileged operation

    Evaluate R4–R6 separately.

    Expected result and reasoning

    R4 denies because Alice is a member. R5 allows billing_admin because Cara has the required role in red. R6 denies because Dan's authority is in blue. Ownership and administrator status each answer only part of the question.

  4. 4. Project the response

    Write the exact public GET fields after R1 and identify the absent values.

    Expected result and reasoning

    Return {id:A,displayName:Alice2,locale:fr}. Do not include role, tenant or internalRiskReview under this specific public contract. Merely hiding pending in the UI would still disclose it if present in JSON.

Your deliverable: A write/function/read matrix with exact stored and serialized fields.

Paper exercise: the stored profile, callers and policy are supplied. profile-update.mjs and the C# Program.cs above run R1 to R3 and the malformed bodies in code, and the express-api project below applies the same rules to a running API.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

Mass assignment lab: Update only the fields users may edit

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Node.js 22+ · npm · 45–60 min

Secure a real HTTP API

Run Express requests with real signed tokens. Repair invoice authorization and stop user-controlled properties from changing roles or tenants.

Needs: Internet access for npm ci, which downloads the pinned packages; the tests run on localhost only.

In the extracted folder, run:

Shell
npm ci --ignore-scripts
npm test

14 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image, and includes Semgrep rules with their own tests in semgrep/.

Part of the learning path Developer foundations

Localhost tests with synthetic identities and in-memory records; includes actual JOSE verification.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Mass Assignment
  2. OWASP Authorization
  3. OWASP API3:2023 Broken Object Property Level Authorization
  4. OWASP API5:2023 Broken Function Level Authorization
  5. Rails API: ActionController::Parameters
  6. .NET: handle overflow JSON or unmapped members
  7. NVD: CVE-2012-2055 (GitHub mass assignment)

Reference information

ASVS 5.0.0 requirements
v5.0.0-8.2.1 · v5.0.0-8.2.3 · v5.0.0-15.3.1 · v5.0.0-15.3.3

Download this guide as Markdown