Skip to content

Commit d8fbed4

Browse files
authored
Merge branch 'main' into security/stream-request-honors-credentials
2 parents 5d9721c + 82f0eba commit d8fbed4

7 files changed

Lines changed: 62 additions & 4 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
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
});

packages/storage/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-storage",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Framework-agnostic localStorage service factory with prefix namespacing",
55
"homepage": "https://packages.script.nl/packages/storage",
66
"license": "MIT",

packages/storage/src/storage.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { StorageService, Get } from "./types";
22

33
export const createStorageService = (prefix: string): StorageService => {
4+
if (prefix.includes(":")) {
5+
throw new Error(
6+
`createStorageService: prefix must not contain ":" — got ${JSON.stringify(prefix)}. The colon is reserved as the prefix/key separator; a prefix containing ":" would allow clear() to match and delete keys from other prefixes (e.g., prefix "app" would delete everything stored under "app:admin").`,
7+
);
8+
}
9+
410
const prefixKey = (key: string): string => `${prefix}:${key}`;
511

612
const put = (key: string, value: unknown): void => {

packages/storage/tests/storage.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ describe("storage service", () => {
2828
expect(storage).toHaveProperty("remove");
2929
expect(storage).toHaveProperty("clear");
3030
});
31+
32+
it("should throw when the prefix contains a colon", () => {
33+
expect(() => createStorageService("app:admin")).toThrow(/must not contain ":"/u);
34+
expect(() => createStorageService(":leading")).toThrow(/must not contain ":"/u);
35+
expect(() => createStorageService("trailing:")).toThrow(/must not contain ":"/u);
36+
});
37+
38+
it("should include the offending prefix in the error message", () => {
39+
expect(() => createStorageService("bad:prefix")).toThrow(/"bad:prefix"/u);
40+
});
3141
});
3242

3343
describe("put", () => {

0 commit comments

Comments
 (0)