CyberNugget

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. CustomerUpdate holds the fields a customer may write; CustomerView holds 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.
  • @JsonIgnore and select: false are 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.

Deep dive 1

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

  1. 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.
  2. Use structures without a prototype for client-keyed data: new Map(), or Object.create(null).
  3. If you must merge, skip dangerous keys (__proto__, constructor, prototype) and only recurse into own properties (Object.hasOwn(src, k)).
  4. Keep merge and path-setting libraries patched, and run npm audit in CI (L49 covers image scanning; dependency risk is L55).
  5. 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__ and constructor.prototype both reach Object.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.
Deep dive 2

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: false doesn't compose with allOf; use unevaluatedProperties (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.