Skip to content

Commit 4202cf4

Browse files
authored
Merge pull request #26 from script-development/security/deepcopy-prototype-pollution
security(helpers): filter __proto__ and constructor in deepCopy
2 parents c54785b + 4214a54 commit 4202cf4

4 files changed

Lines changed: 44 additions & 2 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/helpers/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-helpers",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Tree-shakeable shared utility helpers: deep copy, type guards, and case conversion",
55
"homepage": "https://packages.script.nl/packages/helpers",
66
"license": "MIT",

packages/helpers/src/deep-copy.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export type Writable<T> = T extends WritablePrimitive
1818
*
1919
* Handles: primitives, plain objects, arrays, Date, null.
2020
* Does NOT handle: Map, Set, RegExp, functions, circular references.
21+
*
22+
* Security: skips `__proto__` and `constructor` keys to prevent prototype
23+
* pollution when copying untrusted input (e.g., `JSON.parse` of external
24+
* data, where these keys are treated as literal own properties).
2125
*/
2226
export const deepCopy = <T>(toCopy: T): Writable<T> => {
2327
if (typeof toCopy !== "object" || toCopy === null) return toCopy as Writable<T>;
@@ -29,6 +33,7 @@ export const deepCopy = <T>(toCopy: T): Writable<T> => {
2933
const copiedObject: Record<string, unknown> = {};
3034

3135
for (const key of Object.keys(toCopy)) {
36+
if (key === "__proto__" || key === "constructor") continue;
3237
copiedObject[key] = deepCopy((toCopy as Record<string, unknown>)[key]);
3338
}
3439

packages/helpers/tests/deep-copy.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,41 @@ describe("deepCopy", () => {
7070
expect(copy.b.c).toBe(99);
7171
expect(original.a).toBe(1);
7272
});
73+
74+
describe("prototype pollution resistance", () => {
75+
it("should not set the prototype from a JSON-parsed __proto__ key", () => {
76+
// JSON.parse treats __proto__ as a literal own property, unlike object literals.
77+
const malicious = JSON.parse('{"__proto__": {"polluted": "yes"}}') as Record<string, unknown>;
78+
79+
const copy = deepCopy(malicious) as Record<string, unknown>;
80+
81+
expect(Object.getPrototypeOf(copy)).toBe(Object.prototype);
82+
expect(copy.polluted).toBeUndefined();
83+
});
84+
85+
it("should not copy a literal constructor key from external data", () => {
86+
const malicious = JSON.parse('{"constructor": {"prototype": {"polluted": "yes"}}}') as Record<
87+
string,
88+
unknown
89+
>;
90+
91+
const copy = deepCopy(malicious);
92+
93+
expect(Object.hasOwn(copy, "constructor")).toBe(false);
94+
// Sanity: ensure Object.prototype was not polluted by the copy operation.
95+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
96+
});
97+
98+
it("should preserve safe keys when dangerous keys are present", () => {
99+
const malicious = JSON.parse(
100+
'{"__proto__": {"polluted": "yes"}, "safe": 1, "other": "keep"}',
101+
) as Record<string, unknown>;
102+
103+
const copy = deepCopy(malicious) as Record<string, unknown>;
104+
105+
expect(copy.safe).toBe(1);
106+
expect(copy.other).toBe("keep");
107+
expect(copy.polluted).toBeUndefined();
108+
});
109+
});
73110
});

0 commit comments

Comments
 (0)