demonstrates a production-ready TypeScript utility that enforces these principles.
Step 1: Define Bypass Token Allowlist
Prototype pollution relies on three specific keys that escape the target object and resolve to shared prototypes. We isolate them in a frozen set for O(1) lookup.
const PROTOTYPE_BYPASS_TOKENS = new Set<string>([
"__proto__",
"constructor",
"prototype"
]);
Object.freeze(PROTOTYPE_BYPASS_TOKENS);
Step 2: Implement Safe Recursive Composition
The merger validates keys before traversal, prevents array corruption, and allocates intermediate nodes without prototype chains.
type Primitive = string | number | boolean | null | undefined;
type SafeObject = Record<string, unknown>;
function composeSecureObject(
target: SafeObject,
source: unknown
): SafeObject {
if (typeof source !== "object" || source === null) {
return target;
}
const sourceObj = source as SafeObject;
for (const key of Object.keys(sourceObj)) {
// Block prototype escape vectors before recursion
if (PROTOTYPE_BYPASS_TOKENS.has(key)) {
continue;
}
const sourceValue = sourceObj[key];
if (
typeof sourceValue === "object" &&
sourceValue !== null &&
!Array.isArray(sourceValue)
) {
// Allocate null-prototype intermediate to break chain inheritance
if (!Object.prototype.hasOwnProperty.call(target, key)) {
target[key] = Object.create(null);
}
composeSecureObject(target[key] as SafeObject, sourceValue);
} else {
target[key] = sourceValue;
}
}
return target;
}
Step 3: Runtime Prototype Hardening
Freezing Object.prototype at application startup converts silent corruption into detectable failures. In strict mode, writes throw TypeError; in sloppy mode, they fail silently. Both outcomes are preferable to undetected state mutation.
// Execute once during bootstrap sequence
export function hardenGlobalPrototypes(): void {
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
}
Step 4: Alternative Data Structures for Dynamic Keys
When storing untrusted key-value pairs, prefer Map or null-prototype objects. Map completely bypasses prototype chain resolution, making __proto__ a harmless string key.
const dynamicConfig = new Map<string, unknown>();
dynamicConfig.set("__proto__", { isAdmin: true });
// Map lookup returns the exact value stored, never traverses prototypes
console.log(dynamicConfig.get("__proto__")); // { isAdmin: true }
Architecture Decisions & Rationale
- Key filtering at merge time: Prevents pollution before it reaches the prototype chain. Filtering is cheaper than post-merge validation and stops gadget chains early.
Object.create(null) for intermediates: Eliminates inherited methods (toString, hasOwnProperty) on temporary nodes. This breaks constructor.prototype traversal paths that bypass top-level key checks.
Object.freeze at boot: Defense-in-depth layer. It does not replace input validation but ensures that any bypass attempt fails loudly rather than corrupting state.
Map preference for external keys: Removes prototype chain entirely. Ideal for configuration stores, session caches, and routing tables where keys originate outside the trust boundary.
Pitfall Guide
1. Ignoring Nested constructor.prototype Paths
Explanation: Attackers bypass top-level __proto__ filters by sending {"constructor": {"prototype": {"isAdmin": true}}}. The merge utility resolves constructor to the object's constructor function, then writes to its prototype property.
Fix: Block constructor and prototype alongside __proto__. Validate recursively, not just at the root level.
2. Assuming Object.assign Is Safe
Explanation: Object.assign(target, source) copies enumerable own properties but does not validate keys. If source contains __proto__, it writes directly to the prototype chain.
Fix: Never pass untrusted payloads directly to Object.assign. Use a validated merger or convert to Map first.
3. Overlooking qs Bracket Notation Parsing
Explanation: Express uses qs for query string parsing. By default, qs supports bracket notation (?a[__proto__][b]=1), which constructs nested objects with prototype keys before your route handler executes.
Fix: Pin qs >= 6.10 and explicitly set allowPrototypes: false in Express configuration. Audit all req.query flows.
4. Calling .hasOwnProperty() on Null-Prototype Objects
Explanation: Object.create(null) objects lack prototype methods. Invoking obj.hasOwnProperty(key) throws a TypeError.
Fix: Use Object.prototype.hasOwnProperty.call(obj, key) universally. It works on both standard and null-prototype objects.
5. Skipping Prototype Cleanup in Test Suites
Explanation: A failing test that successfully pollutes Object.prototype leaves the mutation active for subsequent tests in the same process, causing false positives/negatives.
Fix: Implement afterEach hooks that explicitly delete polluted keys from Object.prototype. Run tests in isolated workers (jest --workerIdleMemoryLimit) for critical security suites.
6. Trusting YAML/CSV Parsers Implicitly
Explanation: Libraries like js-yaml in DEFAULT_FULL_SCHEMA mode allow arbitrary JavaScript object construction, including prototype manipulation. CSV parsers that auto-nest fields can also inject __proto__ keys.
Fix: Use js-yaml's load() with DEFAULT_SAFE_SCHEMA. Validate parsed structures against a strict schema (Zod, Joi) before merging.
Explanation: Object.freeze(Object.prototype) prevents writes but does not stop gadget chains that read polluted values before the freeze executes, or libraries that create intermediate objects during initialization.
Fix: Treat prototype freezing as a safety net, not a primary control. Always validate keys at the composition boundary.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
Legacy Express API with heavy req.body merging | Key-filtered merge + Object.freeze | Minimal refactoring, blocks known CVE paths, maintains existing object semantics | Low engineering cost, moderate runtime overhead |
| High-throughput configuration service | Map storage + Zod validation | Eliminates prototype chain entirely, V8 optimizes Map lookups, strict schema enforcement | High initial refactor, near-zero pollution risk |
| Internal CLI tool parsing YAML/JSON | Safe-load parsers + null-prototype intermediates | CLI runs in trusted context but may process third-party configs; null-prototype breaks traversal | Low cost, high safety margin |
| Real-time WebSocket message router | Key-filtered merge + message schema validation | WS payloads bypass HTTP parsers; validation at socket layer prevents gadget chain execution | Medium cost, prevents silent state corruption |
Configuration Template
// security/hardening.ts
import { Express } from "express";
import qs from "qs";
export function applyPrototypeHardening(app: Express): void {
// 1. Freeze global prototypes at startup
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
// 2. Configure query parser to strip prototype keys
app.set("query parser", (str: string) => qs.parse(str, { allowPrototypes: false }));
// 3. Register global error handler for strict-mode prototype write attempts
app.use((err: Error, _req: any, res: any, next: any) => {
if (err instanceof TypeError && err.message.includes("Cannot assign")) {
res.status(400).json({ error: "Invalid payload structure" });
return;
}
next(err);
});
}
// security/composer.ts
const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
export function safeCompose<T extends Record<string, unknown>>(
target: T,
source: unknown
): T {
if (typeof source !== "object" || source === null) return target;
for (const key of Object.keys(source as object)) {
if (BLOCKED_KEYS.has(key)) continue;
const val = (source as Record<string, unknown>)[key];
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
if (!Object.prototype.hasOwnProperty.call(target, key)) {
target[key] = Object.create(null);
}
safeCompose(target[key] as Record<string, unknown>, val);
} else {
target[key] = val;
}
}
return target;
}
Quick Start Guide
- Install dependencies: Ensure
qs >= 6.10.0 and lodash >= 4.17.21 are pinned in package.json. Run npm audit to verify no prototype pollution CVEs remain.
- Bootstrap hardening: Import
applyPrototypeHardening in your application entry point and call it before mounting routes. This freezes prototypes and configures safe query parsing.
- Replace merge calls: Search for
Object.assign, _.merge, _.defaultsDeep, and custom recursive mergers. Replace them with safeCompose from the template. Add type guards where necessary.
- Add regression tests: Create a test file that parses
{"__proto__": {"test": true}}, runs safeCompose, and asserts ({}).test === undefined. Include afterEach cleanup to prevent test pollution.
- Validate in staging: Deploy to a staging environment and run automated fuzz tests against merge endpoints. Monitor error logs for
TypeError: Cannot assign to read only property to confirm Object.freeze is active.