Input Validation

Does obfuscation break mass assignment guards?

Mass assignment protection is a single option on a validation call. It is the line that decides whether an unexpected property in a request body is discarded, rejected, or written straight onto your entity. We pointed member renaming at that option and read what arrived on the other side.

The setup

The sample uses class-validator 0.14, installed in this repository, with its decorators applied by hand exactly as the TypeScript helper emits them, so no decorator syntax is involved and the file is plain ES5-compatible JavaScript. The data transfer object declares two properties: an email and a role constrained to a small set.

The inbound payload is a JSON string with four keys. Two are declared. Two are not: an isAdmin boolean and a large creditLimit. This is the classic over-posting shape, and it matters that those keys are written by the client rather than by the sample, because a name your own file writes and later reads will always agree with itself and measure nothing.

The validation call is made four ways, each with a different non-default option set: strip unknown properties, refuse them outright, allow missing properties on a partial update, and the permissive legacy path. All four values are non-default, because class-validator by default keeps unknown properties, does not refuse them, and does not skip missing ones.

The privilege that arrived in the request body

Renaming whitelist changed the kept property list from email,role to email,role,isAdmin,creditLimit. Our reported flag moved from privilege-escalated=false to privilege-escalated=true, and the credit limit from the payload was set on the object as well.

Nothing else in the run changed. The declared properties still validated. The email constraint still ran. The role was still checked against its allowed set. Validation reported zero errors, correctly, because everything it was asked to check passed. The two properties that should never have survived the door were simply never considered.

What happens next is entirely up to your persistence layer, and most of them are enthusiastic. An object spread into an update, an ORM that maps declared columns, a document store that takes whatever it is given: each of those turns a stripped property into a stored one. The validation step is where the decision was supposed to be made, and it was made by an option name.

The rejection that turned into a silent success

The second call pairs whitelist with forbidNonWhitelisted, which is the stricter posture: do not quietly discard unexpected properties, refuse the request. In the unprotected run that produced two validation errors, the first naming isAdmin as the offending property.

Renaming forbidNonWhitelisted alone moved that to zero errors. The unknown properties were still stripped, so nothing dangerous reached the entity, but the request that your API contract says must be rejected with a 400 was instead accepted with a 200. Renaming whitelist alone did worse: zero errors and the properties kept.

Both directions matter for an audit trail. A rejected request leaves a record that somebody tried to set a property they should not have. A silently stripped one leaves nothing at all, which is why the strict posture exists in the first place, and why losing it is a security event even when no data is corrupted.

The two arms that failed closed

Not every rename made things more permissive. The partial-update call passes skipMissingProperties, which is what a PATCH endpoint needs so that omitting a field is not the same as clearing it. Renaming that option took our error count from zero to one: valid partial updates start being rejected.

The legacy path pins forbidUnknownValues to false, which is the permissive direction on an option that class-validator now defaults to true. Renaming it reverted to the default and produced an error on an object that had been accepted, again failing closed.

This is the same split the rest of this series keeps finding. The restrictive options, the ones doing security work, fail open. The permissive ones, the ones smoothing over a legacy shape, fail closed. A single transform produces both outcomes in one file, and which one you get depends only on which way your value points.

When the failure report stops naming anything

One arm targeted the error objects rather than the options. Class-validator writes property and constraints onto every validation error, and our sample reads both to build a report. Renaming those two names left validation working exactly as before and hollowed out the report: the offending property became undefined and the constraint list became empty.

That is a quiet, specific kind of damage. The API still returns 400. The counter still increments. The dashboard still shows a validation failure rate. What disappears is the ability of anyone reading the log to tell which field failed and why, which is the entire value of the record.

A related arm is a good reminder to run the operation a name actually governs. Our first attempt at measuring stopAtFirstError reported no change, because the test payload happened to satisfy the second constraint on the field and only one constraint ever failed. With a payload that fails both, renaming the option produced the expected extra constraint in the report. An arm that measures nothing is not evidence that the name is inert.

Why this one deserves more care than most

Most results in this series break something a user or an operator eventually notices. This one adds a capability nobody asked for, on the request path, in a component whose whole job is to say no. There is no error, no exception, no changed status code and no latency signature. The only visible artifact is a stored property that should not exist.

It is also the arm most likely to be missed by the tests you already have. Validation tests generally assert that good input is accepted and that bad values on declared fields are rejected. Very few assert that an undeclared property does not survive, because in normal operation the framework guarantees that and there is nothing to test.

So write that one test. Post a body with a property your object does not declare, then assert on the persisted record rather than the response, and assert that the property is absent. It runs in a second, it fails loudly, and it is one of the few checks that would catch this in a protected build.

What to exclude, and what to assert

The exclusion list here is five names long: whitelist, forbidNonWhitelisted, forbidUnknownValues, skipMissingProperties and stopAtFirstError. Add the two error-object names, property and constraints, if you read them anywhere. That is the whole surface for this library, and excluding them costs nothing worth measuring.

More generally, treat any options object handed to a validation library as an external interface, in the same category as an HTTP header or a JSON body. The keys are read by a parser you did not write, and a key that is missing is not an error, it is a default. That is the mechanism behind every result on this page.

The engine's member renaming is off unless you turn it on, and it is scoped by a regular expression you supply. A pattern restricted to names your own code both writes and reads cannot produce any of this. Everything above is what happens when the pattern is drawn wider than the code you own.

Frequently asked questions

Can obfuscation cause a mass assignment vulnerability?

Member renaming can, if the pattern matches the option name that turns the guard on. In our measurement renaming the whitelist option let an isAdmin flag and a large credit limit from the request body reach the object, with validation reporting no errors at all.

Does protection alone do this?

No. Protection with no member renaming reproduced our sample's output exactly on all five presets, including both targets and the compressed profile. The transform involved here is opt-in and scoped by a regular expression you write.

Why does validation still report success?

Because everything it was asked to check did pass. The declared fields validated normally. Unknown properties are only considered when the option that asks for them to be considered is present, so removing the option removes the question rather than the answer.

What is the difference between the whitelist and forbid options?

Whitelist strips undeclared properties; the forbid option turns the same condition into a validation error. Renaming the first let the properties through, and renaming the second turned a request that should have been rejected into a silent success with the properties stripped.

Do any of these renames fail loudly?

Yes, the permissive ones. Renaming the option that skips missing properties made valid partial updates start failing, and renaming a legacy switch that relaxes unknown-value checking reverted it to the stricter default. Restrictive options fail open; permissive ones fail closed.

What happens to the validation error report?

The error objects carry property and constraints names written by the library. Renaming those left validation working and emptied the report: the offending field read as undefined and the constraint list was blank, so the log no longer says which field failed.

What single test would catch this?

Post a body containing a property your object does not declare, then assert on the persisted record rather than the HTTP response that the property is absent. That assertion does not depend on any renamable name and fails immediately in a protected build.

Related reading