L41 · Mass assignment and excessive data exposure
Module 6: API Security · ~20 min · Lesson 41 of 71
🎯 What you'll learn
- Why authorization has a third level, below the function and the object: the individual field
- How auto-binding turns
{"kycStatus":"VERIFIED"}in a PATCH into a skipped KYC check - Why returning a whole MongoDB document leaks data even when the app never displays it
- How to fix both with separate input and output DTOs, allow-lists and projections, in Spring and Node
🔥 Why this matters
Picture a profile endpoint that lets customers change their name and phone number. A tester opens the
mobile app's traffic in a proxy, copies the PATCH, and adds one line: "dailyLimit": 1000000.
The service saves it. The GET on the same screen already returned fraudScore and the full
SSN; the app just didn't show them. Both endpoints passed the BOLA and BFLA checks
from L40, because the caller really was the customer, using an operation they were allowed to call.
1. The third gate: which fields?
L40 had two gates: may you call this function, and may you touch this object? There's a third, inside the object: which fields may you read, and which may you write?
Customer C-2001 READ WRITE
customer ops customer ops system
name ✓ ✓ ✓ ✓
phone ✓ ✓ ✓ ✓
kycStatus ✓ ✓ ✗ ✗ KYC job
dailyLimit ✓ ✓ ✗ 4 eyes
fraudScore ✗ ✓ ✗ ✗ model
ssn ✗ last 4 ✗ ✗
passwordHash ✗ ✗ ✗ ✗ auth svc
Every API has this table, written down or not. Breaking the WRITE side is mass assignment: the API copies client-supplied fields onto an object without checking whether the client may set each one. Breaking the READ side is excessive data exposure: the API returns fields the caller shouldn't see and trusts the client to hide them.
OWASP listed these separately in 2019 (API6 and API3). The 2023 list merged them into API3:2023 Broken Object Property Level Authorization (BOPLA), because the root cause is the same: nobody checked authorization field by field (L39).
2. Mass assignment: the binder does what you told it
Frameworks are helpful. Spring binds JSON onto a class, Mongoose and the Node driver take an object
and $set it, Rails used to assign a hash to a model in one line. That convenience is the bug when
the target class has fields the client must never control.
PATCH /v1/customers/me
{"phone":"555-0199","kycStatus":"VERIFIED"}
│ 1. JSON body, written by the client
▼
┌────────────────┐ 2. copies EVERY key that matches
│ auto-binder │ a field name; it doesn't know
└───────┬────────┘ which fields are customer-editable
▼
┌────────────────┐ 3. kycStatus = VERIFIED, and the
│ Customer │ KYC workflow never ran
│ entity │
└───────┬────────┘
│ 4. save()
▼
MongoDB: customers
The attacker doesn't need to guess blindly. Field names come from the GET response, the OpenAPI
spec, the mobile app's code, or an admin screen that uses the same endpoint. Typical targets:
role, isAdmin, status, ownerId (now it's a BOLA too), limit, price, verified.
The best-known case is GitHub in March 2012. Egor Homakov found that the form for updating an SSH public key accepted a user ID from the request, so he could attach his key to the Rails organization and push a commit to the rails/rails repository to prove it. Rails then moved toward safe defaults, and Rails 4 (2013) made strong parameters, an explicit allow-list per action, the standard way to accept input.
3. Excessive data exposure: the client is not a filter
Reading is the mirror image. The quickest way to write a GET is to load the document and return it.
MongoDB document on the wire the app shows
┌─────────────────────┐
│ _id: "C-2001" │──▶ _id
│ name: "Ana Diaz" │──▶ name ───▶ Ana Diaz
│ email │──▶ email ───▶ ana@example.com
│ ssn: "000-12-3456" │──▶ ssn ✗ not shown, but
│ passwordHash │──▶ passwordHash ✗ readable with curl
│ fraudScore: 0.82 │──▶ fraudScore ✗ or a proxy, and
│ kycNotes: "..." │──▶ kycNotes ✗ cached and logged
└─────────────────────┘
"The app doesn't display it" isn't a control. Anyone with a token can call the API directly, and
everything on the wire also ends up in browser caches, mobile logs, the gateway's request logs if body
logging is on, and the next team's integration that starts depending on fraudScore.
MongoDB makes this easy to get wrong because documents grow. A field added next year by a batch job in Airflow appears in every API that returns the whole document, without anyone touching the API's code.
4. The fix: decide per operation, allow-list everything
The pattern is the same in every language:
- Separate input and output types. A DTO (data transfer object) is a class that exists only
to define what crosses the API boundary.
CustomerUpdateholds the fields a customer may write;CustomerViewholds what they may read. The database entity never touches the wire. - Allow-list, don't deny-list. List what's allowed. A new field is then private and read-only until someone decides otherwise.
- Reject unknown fields with a 400 instead of silently ignoring them, so probing is visible and client bugs surface early.
- Project in the query. Ask MongoDB only for the fields the view needs, so sensitive data never even reaches the service's memory.
- Different callers, different DTOs. Ops may see
fraudScore; customers get another view.
Why not @JsonIgnore on the sensitive entity fields? It's a deny-list on the shared entity, so
every new field is exposed and writable by default. It's global to the class, so hiding ssn from
customers also hides it from the ops API, and someone eventually deletes the annotation. It only
affects Jackson, not toString() in logs or a Kafka message sent with another serializer. And
Spring's @InitBinder setDisallowedFields doesn't help either: it guards form and query parameter
binding, not @RequestBody JSON.
🧪 Worked example: one PATCH, two stacks
Spring, vulnerable. The entity is the request body and the response:
@PatchMapping("/v1/customers/me")
Customer patch(@AuthenticationPrincipal Jwt jwt,
@RequestBody String body) throws IOException {
Customer c = repo.findById(jwt.getSubject()).orElseThrow();
mapper.readerForUpdating(c).readValue(body); // ANY field
return repo.save(c); // EVERY field
}
Spring, fixed. Records as DTOs, explicit copying, a view built field by field:
public record CustomerUpdate(
@Size(max = 100) String name,
@Pattern(regexp = "\\+?[0-9 -]{7,20}") String phone) {}
public record CustomerView(String id, String name, String email) {
static CustomerView of(Customer c) {
return new CustomerView(c.getId(), c.getName(), c.getEmail());
}
}
@PatchMapping("/v1/customers/me")
CustomerView patch(@AuthenticationPrincipal Jwt jwt,
@Valid @RequestBody CustomerUpdate in) {
Customer c = repo.findById(jwt.getSubject()).orElseThrow();
if (in.name() != null) c.setName(in.name());
if (in.phone() != null) c.setPhone(in.phone());
return CustomerView.of(repo.save(c));
}
Spring Boot configures Jackson to ignore unknown properties, so {"kycStatus":"VERIFIED"} would
simply be dropped. To turn that into a 400, set
spring.jackson.deserialization.fail-on-unknown-properties=true (the property name as of Spring
Boot 3; check yours). For reads, a Spring Data closed interface projection such as
Optional<CustomerSummary> findSummaryById(String id) limits the fields MongoDB returns.
Node + MongoDB, vulnerable. $set with the raw body also accepts dotted paths, so
{"limits.daily": 1000000} reaches a nested field:
app.patch("/v1/customers/me", async (req, res) => {
const id = req.auth.sub;
await customers.updateOne({ _id: id }, { $set: req.body });
res.json(await customers.findOne({ _id: id })); // whole doc
});
Node + MongoDB, fixed, using Zod for the input schema:
const CustomerUpdate = z.object({
name: z.string().max(100).optional(),
phone: z.string().regex(/^\+?[0-9 -]{7,20}$/).optional(),
}).strict(); // unknown keys -> error
const VIEW = { projection: { name: 1, email: 1 } };
app.patch("/v1/customers/me", async (req, res) => {
const r = CustomerUpdate.safeParse(req.body);
if (!r.success) return res.status(400).json({ error: "invalid" });
const id = req.auth.sub;
await customers.updateOne({ _id: id }, { $set: r.data });
res.json(await customers.findOne({ _id: id }, VIEW));
});
A MongoDB projection includes _id unless you exclude it with _id: 0. In Mongoose, a schema field
marked select: false is left out by default, but .select("+field") brings it back and
aggregate() doesn't apply it, so treat it as a safety net, not the view.
🛠 Try it (optional, ~5 min)
No packages needed. Save this as mass.js in a scratch folder:
// mass.js: what does each version store?
const stored = { id: "C-2001", name: "Ana Diaz", phone: "555-0100",
kycStatus: "PENDING", dailyLimit: 1000 };
const body = JSON.parse(process.argv[2]);
// VULNERABLE: copy whatever the client sent
console.log("vulnerable:", { ...stored, ...body });
// FIXED: allow-list, and reject anything else
const EDITABLE = new Set(["name", "phone"]);
const extra = Object.keys(body).filter((k) => !EDITABLE.has(k));
if (extra.length) console.log("fixed: 400 not editable:", extra);
else console.log("fixed:", { ...stored, ...body });
Run it with a sneaky body:
$ node mass.js '{"phone":"555-0199","kycStatus":"VERIFIED",
"dailyLimit":1000000}'
vulnerable: { id: 'C-2001', name: 'Ana Diaz', phone: '555-0199',
kycStatus: 'VERIFIED', dailyLimit: 1000000 }
fixed: 400 not editable: [ 'kycStatus', 'dailyLimit' ]
(Node prints each object over several lines; it's compressed here.) Run it again with only
{"phone":"555-0199"}: both versions agree. Then think about your own services: which endpoint
returns an entity or a raw Document, and which takes one as @RequestBody?
🧠 Key takeaways
- Authorization has three levels: function (BFLA), object (BOLA) and property (BOPLA, API3:2023).
- Mass assignment is the write side: the binder sets fields the client must never control.
- Excessive data exposure is the read side: the client hiding a field doesn't protect it.
- Fix both with per-operation input and output DTOs, allow-lists, unknown-field rejection and query projections.
@JsonIgnoreandselect: falseare deny-lists on a shared entity: useful nets, poor designs.
📖 Words to know
| Term | Meaning |
|---|---|
| Mass assignment | Copying client-supplied fields onto an object without checking whether the client may set each one |
| Excessive data exposure | Returning fields the caller isn't allowed to see and relying on the client to hide them |
| BOPLA | Broken Object Property Level Authorization, OWASP API3:2023: reading or writing fields you shouldn't |
| DTO | Data transfer object: a type that defines exactly what crosses the API boundary for one operation |
| Allow-list | Listing what's permitted, so anything new is denied by default |
| Projection | A query option that returns only named fields from a MongoDB document |
| Strong parameters | Rails's per-action allow-list of input fields, the default since Rails 4 |
⏭ Next time
L42 is about cost: rate limiting with token buckets and sliding windows, and configuring Kong's rate-limiting plugin so the limit still holds when you run more than one gateway pod.
Prototype pollution: mass assignment's JavaScript cousin
Mass assignment sets a field on one object. In JavaScript, a careless merge can set a field on every object in the process. That's prototype pollution, and it comes from the same habit: copying client-supplied keys without an allow-list.
Why one write reaches every object
Almost every JavaScript object inherits from Object.prototype. If a property isn't found on the
object itself, the lookup continues up the chain. So if isAdmin is written onto
Object.prototype, then {}.isAdmin is true everywhere, for every request, until the process
restarts.
request body {"__proto__": {"isAdmin": true}}
│
│ 1. naive deep merge walks into target["__proto__"],
│ which is Object.prototype itself
▼
┌──────────────────────────┐
│ Object.prototype │ 2. isAdmin: true written here
└──────────────────────────┘
▲ inherited by ▲ ▲
┌───────────┐ ┌───────────┐ ┌───────────┐
│ prefs {} │ │ session {}│ │ options {}│
└───────────┘ └───────────┘ └───────────┘
3. if (user.isAdmin) ... is now true for objects that
never had the field
JSON.parse turns "__proto__" into an ordinary own key. The damage happens when your code then
assigns through it, as in target[key] = ... inside a recursive merge, or _.set(obj, path, v)
with a client-controlled path.
Worked example: a "save preferences" merge
Save this as pp.js and run it on your machine:
// pp.js: a naive deep merge, like many "merge settings" helpers
function merge(target, src) {
for (const k in src) {
if (typeof src[k] === "object" && src[k] !== null) {
if (!target[k]) target[k] = {};
merge(target[k], src[k]);
} else {
target[k] = src[k];
}
}
return target;
}
const body = JSON.parse(process.argv[2]); // the request body
merge({}, body); // "save preferences"
const otherUser = {}; // unrelated object
console.log("otherUser.isAdmin =", otherUser.isAdmin);
Real output on Node 26:
$ node pp.js '{"theme":"dark"}'
otherUser.isAdmin = undefined
$ node pp.js '{"__proto__":{"isAdmin":true}}'
otherUser.isAdmin = true
$ node --disable-proto=delete pp.js '{"__proto__":{"isAdmin":true}}'
otherUser.isAdmin = undefined
$ node --disable-proto=delete pp.js \
'{"constructor":{"prototype":{"isAdmin":true}}}'
otherUser.isAdmin = true
The last run matters: --disable-proto removes the __proto__ accessor, but
constructor.prototype reaches the same object by another path. Single-key blocklists miss it.
This isn't theoretical. Popular libraries have shipped vulnerable merge functions; lodash's
defaultsDeep was CVE-2019-10744, fixed in 4.17.12. In server code the payoff depends on a
gadget: some later code that reads a property the attacker can now supply, such as a role check,
a template option, or an option passed to child_process.
The fixes, strongest first
- Don't deep-merge request bodies at all. Validate them into a known shape (the
.strict()Zod schema from the main lesson rejects__proto__as an unknown key) and copy named fields. - Use structures without a prototype for client-keyed data:
new Map(), orObject.create(null). - If you must merge, skip dangerous keys (
__proto__,constructor,prototype) and only recurse into own properties (Object.hasOwn(src, k)). - Keep merge and path-setting libraries patched, and run
npm auditin CI (L49 covers image scanning; dependency risk is L55). - Defense in depth:
Object.freeze(Object.prototype)at startup stops writes outright, but test it first, because some libraries legitimately extend prototypes.
Python has a milder cousin, sometimes called class pollution, when code does
setattr(obj, key, value) for every key in a request. The fix is the same allow-list.
🧠 Extra takeaways
- Prototype pollution is mass assignment against the language runtime: one bad key affects every object.
__proto__andconstructor.prototypeboth reachObject.prototype; blocklisting one isn't enough.- Strict schemas and
Map/Object.create(null)remove the problem; key filters only reduce it. - The impact comes from gadgets, so a "harmless" pollution bug can still become privilege escalation.
Locking the contract: schemas that reject extra fields
DTOs fix the code you write today. Schemas and tests keep it fixed when a teammate adds a field next quarter. The goal is a contract that fails loudly when a request carries an unexpected field or a response grows one.
Request side: OpenAPI's default is "anything goes"
In OpenAPI 3.0 (and JSON Schema), an object schema allows extra properties unless you say
otherwise. This schema documents two fields but accepts kycStatus too:
CustomerUpdate:
type: object
properties:
name: { type: string, maxLength: 100 }
phone: { type: string, pattern: '^\+?[0-9 -]{7,20}$' }
additionalProperties: false # without this, extras pass
One well-known trap: additionalProperties: false only sees properties declared in the same
schema object. If you build CustomerUpdate with allOf: [Base, {...}], it rejects the fields from
Base too. OpenAPI 3.1 uses JSON Schema 2020-12, whose unevaluatedProperties: false understands
allOf; with 3.0 you flatten the schema instead.
Where the schema is enforced matters more than where it's written. Options include request validation in the service (for example a Spring or Express OpenAPI validator), and validation at the gateway; Kong has a request-validator plugin, but if you run Kong, check whether your edition includes it (L43 covers what gateways should and shouldn't do).
Response side: test the field list
There's no runtime "reject" for responses, so make it a test. Pin the exact set of fields each view may return, so adding one is a deliberate, reviewed change.
@Test
void customerView_exposesOnlyAgreedFields() throws Exception {
String json = mvc.perform(get("/v1/customers/me")
.with(jwt().jwt(j -> j.subject("C-2001"))))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
Set<String> fields = new HashSet<>();
mapper.readTree(json).fieldNames().forEachRemaining(fields::add);
assertEquals(Set.of("id", "name", "email"), fields);
}
(jwt() comes from spring-security-test. Note Jackson 3, used by Spring Boot 4, renames some
JsonNode methods, so adjust fieldNames() if you're on it.) Add the mirror test for writes: a
PATCH with "kycStatus": "VERIFIED" must return 400 and leave the stored value unchanged.
Worked example: catching a leak in review
A data engineer adds riskTier to the customers collection from an Airflow job. With the old
"return the document" design, it would appear in the customer API the next morning. With the
design above:
| Layer | What happens |
|---|---|
| MongoDB query | Projection asks for name and email only; riskTier never leaves the database |
| Response DTO | CustomerView has no riskTier field, so it can't be serialized |
| Contract test | Still passes, because the field list didn't change |
| If someone adds it to the view | The test fails, and the PR needs a reviewer to accept the new field |
At the database level, MongoDB's $jsonSchema validator can also reject documents with unexpected
fields (L31's deep dive on hardening MongoDB), which protects against mass assignment that bypasses
the API, such as a buggy batch job.
🧠 Extra takeaways
- OpenAPI and JSON Schema allow extra properties by default; set
additionalProperties: false. additionalProperties: falsedoesn't compose withallOf; useunevaluatedProperties(3.1) or flatten.- Pin each response's field set in a test, so new fields need a deliberate review.
- Defense in depth for fields: schema at the edge, DTO in the service, projection in the query.
The quiz for this lesson and the Tests section need JavaScript.