Every plain object inherits from one shared object, Object.prototype: it's where methods like toString and hasOwnProperty come from. Prototype pollution is a security bug in which input from outside the program, such as a query string, a form field's name, or a JSON request body, gets code to write a property onto Object.prototype. From then on, every object in the program seems to have that property.
The way in is a few special key names. On a plain object, obj.__proto__ isn't an ordinary property: it's a getter and setter, inherited from Object.prototype, that reads or replaces the object's prototype. So obj["__proto__"] is Object.prototype itself, and a property written through it is written for everyone:
const settings = {};
const key = "__proto__"; // imagine it came from a query string
settings[key].isAdmin = true; // settings.__proto__ is Object.prototype
({}).isAdmin; // true: every object has it now
const user = { name: "Ada" };
user.isAdmin; // true, though user never had the property
delete Object.prototype.isAdmin; // clean up
constructor and prototype lead to the same place: obj.constructor is Object, and Object.prototype is the shared prototype. Here's where a function that sets a value at a dotted path would write, if it followed each key as given:
| Path | Following it reaches | The value lands on |
|---|---|---|
user.isAdmin | obj.user, an ordinary nested object | obj.user, as intended |
__proto__.isAdmin | obj.__proto__, which is Object.prototype | Object.prototype, so every object |
constructor.prototype.isAdmin | obj.constructor, which is Object, then Object.prototype | Object.prototype, so every object |
Why it's a security bug: code often checks a property that's normally missing, like if (user.isAdmin), and after pollution every object passes. Replacing an inherited method, such as toString, can also make code all over the app throw. Bugs like this have been found and fixed in popular libraries, Lodash and jQuery among them.
One more detail. Assigning obj["__proto__"] = value on a plain object doesn't create a key named __proto__: it replaces obj's prototype with value, or does nothing if value isn't an object or null. JSON.parse, though, does create an ordinary key named __proto__, so parsed JSON can carry one into code that copies or merges keys:
const parsed = JSON.parse('{"__proto__": {"isAdmin": true}}');
Object.keys(parsed); // ["__proto__"]: a real own key, harmless so far
const target = {};
target["__proto__"] = parsed["__proto__"]; // replaces target's prototype
Object.keys(target); // []: no key was created
target.isAdmin; // true, inherited from its new prototype
JSON Patch is a format for describing changes to a JSON document, defined in RFC 6902, one of the Request for Comments documents that internet standards are published in. A patch is a JSON array of operations, each an object with an op that says what to do and a path that says where. Instead of sending a whole document back after changing one field, a client can send a patch that says only what changed, the way you'd send an editor a list of corrections rather than a retyped copy of the letter. Over HTTP, it goes in a PATCH request with the content type application/json-patch+json.
There are six operations. The first three, add, remove, and replace, are the core ones:
op | Also has | What it does |
|---|---|---|
add | value | Puts value at path. On an object, it sets the key, replacing any value already there. In an array, it inserts value at that index, which can be at most the array's length, and moves the later items along. The object or array it goes into must already exist. |
remove | Nothing more | Removes the value at path, which must exist. In an array, the later items move back. |
replace | value | Replaces the value at path, which must exist. |
move | from | Removes the value at from and adds it at path. |
copy | from | Adds a copy of the value at from at path. |
test | value | Changes nothing, but fails unless the value at path equals value. |
The operations are applied one at a time, in order, each to the document the one before it left. If one fails, for example because a remove points at nothing or a test doesn't match, the patch as a whole fails, and a server handling a PATCH request must then apply none of it: a document is never left half patched. That's what test is for: put one first, and the patch applies only if the document still holds what you expect, such as the version number you last read.
Each path, and each from, is a JSON Pointer, defined in RFC 6901: a string that points at one value inside a JSON document. The empty string, "", points at the whole document. Any other pointer starts with /, and each / is followed by one step: a key, or an array index written as a number. A key can itself contain / or ~, so inside a key, ~1 stands for / and ~0 stands for ~. In an array, - stands for the place after the last item, so an add there appends. Here are some pointers into { "user": { "name": "Ada", "tags": ["math", "code"] }, "a/b": 1, "m~n": 2 }:
| Pointer | Points at |
|---|---|
"" | The whole document. |
/user | { "name": "Ada", "tags": ["math", "code"] } |
/user/name | "Ada" |
/user/tags/1 | "code", the item at index 1. |
/user/tags/- | The place after "code", where an add appends. |
/a~1b | 1, under the key a/b. |
/m~0n | 2, under the key m~n. |
Here's a shopping list on a server, and a patch a client sends to change it:
// The list on the server:
// { "name": "Groceries", "items": ["milk", "eggs"], "shared": true }
const patch = [
{ op: "add", path: "/items/1", value: "bread" },
{ op: "add", path: "/items/-", value: "jam" },
{ op: "remove", path: "/items/0" },
{ op: "replace", path: "/name", value: "Weekend shop" },
{ op: "add", path: "/owner", value: "Ada" },
{ op: "remove", path: "/shared" },
];
fetch("/api/lists/7", {
method: "PATCH",
headers: { "Content-Type": "application/json-patch+json" },
body: JSON.stringify(patch),
});
// The list afterwards:
// { "name": "Weekend shop", "items": ["bread", "eggs", "jam"], "owner": "Ada" }
| Step | Operation | What changes |
|---|---|---|
| 1 | add at /items/1 | items is ["milk", "bread", "eggs"]: bread goes in at index 1, and eggs moves along to index 2. |
| 2 | add at /items/- | items is ["milk", "bread", "eggs", "jam"]: - appends. |
| 3 | remove at /items/0 | items is ["bread", "eggs", "jam"]: milk goes, and the rest move back. |
| 4 | replace at /name | name is "Weekend shop". |
| 5 | add at /owner | There's a new key, owner, holding "Ada". |
| 6 | remove at /shared | The key shared is gone. |
| A 7th, if there were one | remove at /shared again | Fails, because step 6 removed it. The whole patch fails, so the server keeps the list as it was before step 1. |
You meet JSON Patch in APIs that take PATCH requests, and in tools such as Kubernetes, whose kubectl patch --type=json takes one. JSON Pointers turn up on their own too: in an OpenAPI file, "$ref": "#/components/schemas/User" refers to another part of the file with the pointer after the #. Not every PATCH body is a JSON Patch: JSON Merge Patch, from RFC 7396, sends a partial object instead, with null meaning remove that key, and uses the content type application/merge-patch+json.
A JSON Patch is a list of operations that describe changes to a JSON document, so a client can send only what changed. Write applyPatch(document, operations), which applies the operations in order and returns the new document. Support three operations: add, remove, and replace.
Each operation's path is a JSON Pointer to a place in the document. "" means the whole document. Any other path starts with / and lists keys separated by /: "/user/tags/0" means key user, then key tags, then index 0. Inside a key, ~1 stands for / and ~0 for ~, so "/a~1b" is the key a/b.
{ op: "add", path, value } sets a key of an object, whether it's there already or not. In an array, it inserts value at the index and moves the later items along: the index can be as large as the array's length, and - means the end. Adding at "" makes value the whole document.
{ op: "remove", path } deletes a key of an object, or takes an item out of an array and moves the later items back.
{ op: "replace", path, value } changes the value of a key or array item that's already there. Replacing at "" makes value the whole document.
An operation fails when a step on the way to its target is missing or isn't an object or array, when remove or replace targets a key or index that isn't there, when an array index isn't digits (or - for add) or is past the end, or when its op is unknown. Only own keys count as there, so a patch can't reach toString or __proto__. remove can't target "".
When an operation fails, throw an Error whose message includes its path. Don't change document, whether the patch works or fails.
const doc = { user: { name: "Ada", tags: ["math"] }, draft: true };
applyPatch(doc, [
{ op: "replace", path: "/user/name", value: "Grace" },
{ op: "add", path: "/user/tags/0", value: "code" },
{ op: "add", path: "/user/tags/-", value: "navy" },
{ op: "remove", path: "/draft" },
]);
// { user: { name: "Grace", tags: ["code", "math", "navy"] } }
doc.user.name; // "Ada": the original is unchanged
applyPatch(doc, [{ op: "remove", path: "/user/age" }]);
// throws an Error whose message includes "/user/age"
const result = applyPatch({ name: "Ada", age: 36 }, [
{ op: "replace", path: "/name", value: "Grace" },
{ op: "add", path: "/city", value: "London" },
{ op: "add", path: "/city", value: "Paris" },
{ op: "remove", path: "/age" },
]);
expect(result).toEqual({ name: "Grace", city: "Paris" });
const doc = { users: [{ name: "Ada", tags: ["math"] }, { name: "Alan", tags: [] }] };
const result = applyPatch(doc, [
{ op: "replace", path: "/users/1/name", value: "Grace" },
{ op: "add", path: "/users/0/tags/1", value: "code" },
]);
expect(result).toEqual({ users: [{ name: "Ada", tags: ["math", "code"] }, { name: "Grace", tags: [] }] });
expect(applyPatch([1, 2, 3], [{ op: "add", path: "/1", value: 9 }])).toEqual([1, 9, 2, 3]);
expect(applyPatch([1, 2], [{ op: "add", path: "/2", value: 3 }])).toEqual([1, 2, 3]);
expect(applyPatch([1, 2], [{ op: "add", path: "/-", value: 3 }, { op: "add", path: "/-", value: 4 }])).toEqual([1, 2, 3, 4]);
expect(applyPatch({ list: [] }, [{ op: "add", path: "/list/0", value: "a" }])).toEqual({ list: ["a"] });
expect(applyPatch(["a", "b", "c"], [{ op: "remove", path: "/0" }])).toEqual(["b", "c"]);
expect(applyPatch(["a", "b", "c"], [{ op: "replace", path: "/1", value: "x" }])).toEqual(["a", "x", "c"]);
const doc = { "a/b": 1, "m~n": 2, "~1": 3 };
const result = applyPatch(doc, [
{ op: "replace", path: "/a~1b", value: 10 },
{ op: "replace", path: "/m~0n", value: 20 },
{ op: "replace", path: "/~01", value: 30 },
]);
expect(result).toEqual({ "a/b": 10, "m~n": 20, "~1": 30 });
expect(applyPatch({ a: 1 }, [{ op: "replace", path: "", value: [1, 2] }])).toEqual([1, 2]);
expect(applyPatch({ a: 1 }, [{ op: "add", path: "", value: { b: 2 } }, { op: "add", path: "/c", value: 3 }])).toEqual({ b: 2, c: 3 });
expect(() => applyPatch({ a: 1 }, [{ op: "remove", path: "" }])).toThrow(Error);
const doc = { user: { name: "Ada", tags: ["math"] }, n: 1 };
const result = applyPatch(doc, [
{ op: "replace", path: "/user/name", value: "Grace" },
{ op: "add", path: "/user/tags/-", value: "code" },
{ op: "remove", path: "/n" },
]);
expect(result).toEqual({ user: { name: "Grace", tags: ["math", "code"] } });
expect(result).not.toBe(doc);
expect(doc).toEqual({ user: { name: "Ada", tags: ["math"] }, n: 1 });
expect(() => applyPatch(doc, [{ op: "remove", path: "/n" }, { op: "remove", path: "/missing" }])).toThrow(Error);
expect(doc).toEqual({ user: { name: "Ada", tags: ["math"] }, n: 1 });
expect(() => applyPatch({ a: {} }, [{ op: "remove", path: "/a/b" }])).toThrow("/a/b");
expect(() => applyPatch({ a: {} }, [{ op: "replace", path: "/a/b", value: 1 }])).toThrow("/a/b");
expect(() => applyPatch({}, [{ op: "add", path: "/x/y", value: 1 }])).toThrow("/x/y");
expect(() => applyPatch({ a: 5 }, [{ op: "add", path: "/a/b", value: 1 }])).toThrow("/a/b");
expect(() => applyPatch({ a: null }, [{ op: "add", path: "/a/b", value: 1 }])).toThrow("/a/b");
expect(() => applyPatch({}, [{ op: "remove", path: "/toString" }])).toThrow("/toString");
try {
expect(() => applyPatch({}, [{ op: "add", path: "/__proto__/polluted", value: true }])).toThrow("/__proto__/polluted");
expect(({}).polluted).toBeUndefined();
} finally {
delete Object.prototype.polluted;
}
expect(() => applyPatch({ list: [1] }, [{ op: "add", path: "/list/2", value: 0 }])).toThrow("/list/2");
expect(() => applyPatch({ list: [1] }, [{ op: "replace", path: "/list/1", value: 0 }])).toThrow("/list/1");
expect(() => applyPatch({ list: [1] }, [{ op: "remove", path: "/list/-" }])).toThrow("/list/-");
expect(() => applyPatch({ list: [1] }, [{ op: "add", path: "/list/x", value: 0 }])).toThrow("/list/x");
expect(() => applyPatch({ list: [1] }, [{ op: "add", path: "/list/-1", value: 0 }])).toThrow("/list/-1");
expect(() => applyPatch({ a: 1 }, [{ op: "move", path: "/a" }])).toThrow("/a");
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Apply a JSON Patch. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.