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,ownerand 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.assignor 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.
| Setting | Value | Note | Source |
|---|---|---|---|
| Ruby on Rails unpermitted parameters | config.action_controller.action_on_unpermitted_parameters = :raise | The default is :log in development and test, and false otherwise. | Rails API: ActionController::Parameters |
| .NET System.Text.Json | JsonUnmappedMemberHandling.Disallow on the request type or serializer options | .NET 8 and later; the default skips unknown members silently. | .NET API documentation |
| Spring Boot Jackson | spring.jackson.deserialization.fail-on-unknown-properties=true | — | Spring Boot application properties |
| NestJS | new ValidationPipe({whitelist: true, forbidNonWhitelisted: true}) | — | NestJS validation documentation |
| Pydantic and FastAPI | model_config = ConfigDict(extra="forbid") on request models | — | Pydantic configuration documentation |
| Laravel | $fillable on each model, plus Model::preventSilentlyDiscardingAttributes() | — | Laravel Eloquent documentation |
- PATCH your own profile with
role,tenantand a nested copy of them, and confirm a 400 and an unchanged stored row. - Call each admin route directly as a member, as an administrator of another tenant and unauthenticated; each must be denied.
- Compare every response body with its documented projection and assert sensitive fields are absent, not merely empty.
- Add a column in a test migration and confirm it is neither writable nor returned until a contract adds it.
- 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
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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 practiceObject 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.
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.
// 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');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/.
| Framework | Bind only the writable fields | Make extra fields an error |
|---|---|---|
| Ruby on Rails | params.expect(profile: [:display_name, :locale]) in Rails 8, or require(:profile).permit(...) | action_on_unpermitted_parameters = :raise |
| Django REST framework | A serializer with an explicit fields list and read_only_fields | Unknown keys are dropped silently; compare initial_data with the declared fields to reject them |
| Django forms | ModelForm with fields = ["display_name", "locale"], never "__all__" | Fields outside the form are ignored |
| Spring MVC | A request DTO per operation, never the entity; WebDataBinder.setAllowedFields for form binding | spring.jackson.deserialization.fail-on-unknown-properties=true |
| ASP.NET Core | A DTO per action; [Bind("DisplayName,Locale")] for MVC form posts | JsonUnmappedMemberHandling.Disallow (.NET 8 and later) |
| Laravel | $fillable on the model and $request->validated() | Model::preventSilentlyDiscardingAttributes() |
| Express with Mongoose | Pick the allowed keys before findOneAndUpdate | strict: 'throw' on the schema for paths it does not define |
| NestJS | DTO classes with class-validator decorators | ValidationPipe with whitelist: true and forbidNonWhitelisted: true |
| FastAPI and Pydantic | A request model with only the writable fields | model_config = ConfigDict(extra="forbid") |
// 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);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.
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.
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
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. Compare assignment with projection
Evaluate R1 and R2 under the stated policy, including final
displayNameandrole.Expected result and reasoning
R1 succeeds:
Alice2,fr,member. R2 rejects the whole request with 400;displayNameremainsAliceandroleremainsmember. Blind assignment would incorrectly allow role escalation.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. Authorize the privileged operation
Evaluate R4–R6 separately.
Expected result and reasoning
R4 denies because Alice is a member. R5 allows
billing_adminbecause 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. 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 includerole,tenantorinternalRiskReviewunder this specific public contract. Merely hidingpendingin the UI would still disclose it if present in JSON.
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
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.
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:
npm ci --ignore-scripts
npm test14 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.
Reference information
- SDLC phase
- DevelopmentTesting
- 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