Quick answer: Valid JSON can still carry keys that become dangerous when JavaScript code assigns or recursively merges them. Check five boundaries: the copying operation, accepted fields, dictionary storage, inherited reads, and downstream consumers. Passing a syntax check proves none of these boundaries safe.
Your settings endpoint accepts a small JSON document. The parser succeeds. The response looks normal. Yet the object used by the next function has a different prototype. That is the gap this checklist tests: not whether JSON is readable, but what your application does with its keys.
These checks complement the broader JSON security guide. They focus on JavaScript object behavior rather than transport security, authentication, or every possible injection attack. The examples use local fixtures and assertions; they are not evidence that a particular live service is vulnerable.
1. Does the copying operation turn a JSON key into prototype behavior?
Check parsing and copying separately. A parsed proto member is ordinary data, but assigning that member onto a normal object can invoke an inherited setter and change the target prototype. Assert own properties and prototypes before and after the exact copying operation your endpoint uses, rather than judging the JSON text alone.
Consider a profile preferences object that will eventually become options for another function. The server accepts arbitrary fields because the feature started as a convenient dictionary. That convenience is the trust boundary: user-selected names are becoming JavaScript property operations.
Run this demonstration in an isolated Node.js process with its default legacy accessor behavior:
import assert from 'node:assert/strict';
const input = JSON.parse('{"__proto__":{"fixtureFlag":"from-input"}}');
assert.equal(Object.hasOwn(input, '__proto__'), true);
assert.equal(input.fixtureFlag, undefined);
const assigned = Object.assign({}, input);
assert.equal(assigned.fixtureFlag, 'from-input');
assert.equal(Object.hasOwn(assigned, 'fixtureFlag'), false);
assert.equal(Object.getPrototypeOf(assigned), input.__proto__);
assert.equal(({}).fixtureFlag, undefined);
const spread = { ...input };
assert.equal(Object.getPrototypeOf(spread), Object.prototype);
assert.equal(Object.hasOwn(spread, '__proto__'), true);
assert.equal(spread.fixtureFlag, undefined);
Notice the final assertion about an unrelated empty object. This example changes one target's prototype; it does not write a property onto the shared Object.prototype. MDN's prototype pollution guide distinguishes this assignment behavior. Keeping those outcomes separate makes incident reports and regression tests more useful. An altered options object can still matter without globally contaminating the runtime.
Object.assign documentation explains that assignment uses target setters. Spread creates properties differently, but it also keeps the suspicious member available to later consumers. Neither operation defines which business fields should be accepted.
For general terminology and historical background, see Wikipedia's prototype pollution overview. The runtime behavior in this checklist is grounded in executable assertions and the linked technical documentation, not that background page alone.
2. Does the endpoint accept only explicitly supported fields?
Define allowed fields at each input boundary and reject unexpected names before building application options. Validate nested objects as their own contracts, including types and resource limits. Prefer explicit field mapping over a generic recursive merge when the endpoint only supports a small set of settings with known meanings and permissions.
A notification endpoint that accepts a theme and a Boolean email preference does not need a universal object merger. It needs two fields. A narrow mapper makes that policy visible during review and prevents future callers from accidentally treating submitted options as trusted infrastructure configuration.
This deliberately small example rejects unknown fields instead of silently deleting them:
import assert from 'node:assert/strict';
function readPreferences(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Expected a JSON object');
}
const allowed = new Set(['theme', 'emailNotifications']);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) throw new TypeError('Unexpected field');
}
if (!Object.hasOwn(value, 'theme') ||
!['light', 'dark'].includes(value.theme)) {
throw new TypeError('Invalid theme');
}
if (!Object.hasOwn(value, 'emailNotifications') ||
typeof value.emailNotifications !== 'boolean') {
throw new TypeError('Invalid email preference');
}
return { theme: value.theme, emailNotifications: value.emailNotifications };
}
assert.deepEqual(readPreferences(JSON.parse(
'{"theme":"dark","emailNotifications":false}'
)), { theme: 'dark', emailNotifications: false });
for (const raw of [
'{"theme":"dark","emailNotifications":false,"__proto__":{}}',
'{"theme":"dark","emailNotifications":false,"constructor":{}}',
'{"theme":"dark","emailNotifications":"false"}',
'null', '[]'
]) assert.throws(() => readPreferences(JSON.parse(raw)), TypeError);
Use an established schema validator when the contract grows, and configure every nested object deliberately. An outer restriction does not automatically establish the policy for child objects. Our JSON Schema guide explains structural contracts; authorization still belongs in application code.
Input shape is only part of this check. Confirm that a permitted field cannot overwrite a server-owned value such as tenant identity or access level. A perfectly ordinary role property can create a mass-assignment bug without involving prototypes at all. Build output objects from approved values and derive privileged context from the authenticated session.
Choose rejection semantics before deployment. Returning a client error for an unsupported field is easier to debug than silently accepting a request whose meaning changes after filtering. Record a stable error code and a safe field path, without echoing the entire request body into logs. Keep body-size and nesting limits outside this small mapper: a correct field policy does not prevent expensive parsing of an oversized document.
The Fastify secure-json-parse project documents parser-level rejection policies for prototype-related structures. Such controls can be useful, but their behavior must match the endpoint's actual contract. A parser package cannot decide which user is allowed to change a setting.
3. Are arbitrary labels stored without treating them as object paths?
Store arbitrary user-selected labels in a Map or a deliberately constructed null-prototype dictionary, and keep labels as literal keys. Do not split submitted strings into paths or recurse into inherited objects. Verify that serialization and later conversions preserve this boundary instead of rebuilding an ordinary object through unsafe assignment operations.
Sometimes an allowlist is the wrong data model. A dashboard may let users create custom column labels, including a label named constructor. The label is legitimate text. The problem begins if a helper treats it as an instruction to traverse an existing JavaScript object.
Map documentation describes explicit entry access through set and get. Use those methods consistently; treating a Map as a bracket-indexed object defeats the reason for choosing it. Also plan the wire format: JSON.stringify does not automatically serialize Map entries as an ordinary JSON dictionary.
For an object-shaped dictionary, Object.create(null) creates a value without Object.prototype in its chain. That property applies to the object you created, not recursively to every nested value stored inside it.
import assert from 'node:assert/strict';
const labels = JSON.parse('{"__proto__":"literal label","constructor":"column"}');
const dictionary = Object.create(null);
for (const [key, value] of Object.entries(labels)) {
if (typeof value !== 'string') throw new TypeError('Expected a label');
dictionary[key] = value;
}
assert.equal(Object.getPrototypeOf(dictionary), null);
assert.equal(dictionary.__proto__, 'literal label');
assert.equal(Object.hasOwn(dictionary, 'constructor'), true);
const restored = JSON.parse(JSON.stringify(dictionary));
assert.equal(Object.hasOwn(restored, '__proto__'), true);
assert.equal(Object.getPrototypeOf(restored), Object.prototype);
The restored object demonstrates why the lifecycle matters. Serialization preserved the label, but parsing did not preserve the null prototype. Document where the dictionary representation begins and ends. If another service copies that restored value with Object.assign, you are back at the first check.
Inspect a redacted fixture in the JSON Viewer to locate nested members and copy their paths. The viewer helps you understand the document; it does not certify that a merge library, endpoint, or dependency handles those paths safely. Avoid putting production secrets into debugging fixtures even when the inspection tool runs locally in your browser.
4. Do security decisions distinguish own properties from inherited values?
Read security-sensitive values from trusted, explicitly constructed objects, and distinguish own properties from inherited ones. Object.hasOwn checks ownership without trusting a method supplied on the object itself. Reject missing authorization data rather than treating an inherited truthy value as approval, and test both absence and deliberate false values in your decision logic.
Fixing the write side is important, but a consumer can remain fragile if it trusts any property that resolves through normal lookup. A helper that asks whether options.isAdmin is truthy does not reveal whether that value was declared on options or inherited from elsewhere.
The Object.hasOwn reference explains ownership checks, including objects that shadow hasOwnProperty. Use ownership together with a type and value check; ownership by itself does not establish permission.
import assert from 'node:assert/strict';
function explicitlyEnabled(options) {
return Object.hasOwn(options, 'enabled') && options.enabled === true;
}
assert.equal(explicitlyEnabled(Object.create({ enabled: true })), false);
assert.equal(explicitlyEnabled({ enabled: false }), false);
assert.equal(explicitlyEnabled({ enabled: 'true' }), false);
assert.equal(explicitlyEnabled({ enabled: true }), true);
assert.equal(explicitlyEnabled({ hasOwnProperty: null }), false);
This is a configuration-reading example, not an authorization system. Never let a request choose its own administrator flag, even if the property is owned and Boolean. Permissions should come from trusted account or policy data. The ownership test protects a narrower boundary: whether a submitted or constructed object actually declares a setting.
Review iteration too. A for...in loop traverses enumerable inherited properties as well as own ones, while Object.keys enumerates own enumerable string keys. Changing the loop can remove one accidental input source, but it does not make assignment to arbitrary target keys safe.
Add a regression case where the expected field is absent. Many happy-path tests always supply it, hiding the behavior of a fallback or inherited value. Test explicit false separately from absence so a defaulting expression does not silently enable a disabled feature. These are different application states even when both look falsy in a quick console experiment.
5. Do downstream consumers and runtime controls preserve the same boundary?
Trace the validated object through every later transformation, including dependency merges, defaults, serialization, and worker messages. Test the real consumer in an isolated process and pin expected outcomes. Runtime hardening and dependency updates add protection, but neither proves that an application's accepted fields, inherited reads, or nested merge behavior are correct.
The function receiving a request may be careful while a later adapter is not. Draw a short inventory in the test description: parser, validator, mapper, defaults builder, library consumer. Record which component owns each transformation. This makes a failed assertion actionable instead of leaving reviewers with a vague warning about malicious JSON. If copying is part of that chain, review the separate JSON deep-cloning tradeoffs rather than assuming that a clone approves its contents.
Use a fixture matrix with ordinary settings, unexpected top-level names, nested names, arrays, null, and inherited values constructed inside the test. Assert accepted output and rejection behavior, not just whether an exception occurred somewhere. For a rejection, also confirm that no partial state was persisted and no job was queued.
For example, start with a valid preferences request, add one unsupported member, and verify that the stored preferences remain byte-for-byte unchanged after rejection. Then repeat with the member inside a nested object that the endpoint actually supports. That second case catches validation configured only at the outer level. Keep a separate test for an accepted update so the rejection policy cannot pass simply because the endpoint rejects everything.
OWASP's prevention guidance recommends safer dictionary representations and describes runtime measures as additional protection. In Node.js, the disable-proto option supports delete and throw modes for the legacy accessor. Test whichever mode you deploy: it changes the expected result of the first assignment demonstration.
Do not deliberately contaminate a shared test runner's global prototype. Spawn a disposable child process for any test that needs global mutation, use a timeout, and inspect the exit status. The examples here avoid writing to Object.prototype; even so, integration fixtures deserve isolation because the dependency under test may behave differently.
Finally, keep the failure message precise. Report whether a target prototype changed, an unexpected own field survived, an inherited value influenced a decision, or a privileged action occurred. Those observations are stronger than a blanket claim that a document is safe or unsafe. Repeat the tests after changing merge dependencies or introducing a new configuration adapter, because that is where previously inert JSON keys can gain new meaning.