Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 5 additions & 0 deletions .changeset/fix-deep-extend-prototype-pollution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cleverbrush/deep": patch
---

Prevent `deepExtend()` from merging prototype-polluting keys.
27 changes: 0 additions & 27 deletions .changeset/xpenser-framework-affordances.md

This file was deleted.

2 changes: 2 additions & 0 deletions libs/async/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# @cleverbrush/async

## 4.4.0

## 4.3.2

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion libs/async/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@
},
"type": "module",
"types": "./dist/index.d.ts",
"version": "4.3.2"
"version": "4.4.0"
}
6 changes: 6 additions & 0 deletions libs/auth/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @cleverbrush/auth

## 4.4.0

### Patch Changes

- @cleverbrush/schema@4.4.0

## 4.3.2

### Patch Changes
Expand Down
4 changes: 2 additions & 2 deletions libs/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"email": "andrew_zol@cleverbrush.com"
},
"dependencies": {
"@cleverbrush/schema": "^4.3.2"
"@cleverbrush/schema": "^4.4.0"
},
"description": "Transport-agnostic authentication & authorization — JWT, cookies, role-based policies, schema-typed principals",
"files": [
Expand Down Expand Up @@ -43,5 +43,5 @@
},
"type": "module",
"types": "./dist/index.d.ts",
"version": "4.3.2"
"version": "4.4.0"
}
29 changes: 29 additions & 0 deletions libs/client/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# @cleverbrush/client

## 4.4.0

### Minor Changes

- c75bff4: Add framework affordances discovered while reviewing Xpenser:

- `@cleverbrush/env`: add `envBoolean()` for environment-style boolean values
such as `1`, `0`, `yes`, `no`, `on`, and `off`.
- `@cleverbrush/server`: parse `application/x-www-form-urlencoded` request
bodies by default, add `ActionResult.raw()`, and allow ordered
authentication fallback with `trySchemes`.
- `@cleverbrush/client`: add `externalCacheTags()` to
`@cleverbrush/client/cache` for framework-agnostic external cache tag
invalidation, including Next.js `revalidateTag()` as one supported callback.
- `@cleverbrush/knex-schema` / `@cleverbrush/orm`: add
`InferDatabaseRow` / `InferDatabaseValue` row typing helpers and support raw
update expressions plus conditional `where` callbacks in
`onConflict().merge()`.
- `@cleverbrush/orm-cli`: add read-only `cb-orm validate` for live schema drift
checks.

Docs and package READMEs were updated; see https://docs.cleverbrush.com.

### Patch Changes

- Updated dependencies [c75bff4]
- @cleverbrush/server@4.4.0
- @cleverbrush/schema@4.4.0

## 4.3.2

### Patch Changes
Expand Down
6 changes: 3 additions & 3 deletions libs/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"email": "andrew_zol@cleverbrush.com"
},
"dependencies": {
"@cleverbrush/schema": "^4.3.2",
"@cleverbrush/server": "^4.3.2"
"@cleverbrush/schema": "^4.4.0",
"@cleverbrush/server": "^4.4.0"
},
"devDependencies": {
"@tanstack/react-query": "^5.75.0",
Expand Down Expand Up @@ -102,5 +102,5 @@
},
"type": "module",
"types": "./dist/index.d.ts",
"version": "4.3.2"
"version": "4.4.0"
}
2 changes: 2 additions & 0 deletions libs/deep/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# @cleverbrush/deep

## 4.4.0

## 4.3.2

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion libs/deep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ deepEqual([1, 2, 3], [3, 1, 2], { disregardArrayOrder: true });
Deeply merges multiple objects. Works like `Object.assign`, but recursively merges nested objects instead of overwriting them. All arguments must be non-null objects.

Returns a new object that is the deep merge of all provided objects.
Keys that can mutate the prototype chain (`__proto__`, `constructor`, and `prototype`) are ignored.

```typescript
import { deepExtend } from '@cleverbrush/deep';
Expand Down Expand Up @@ -156,4 +157,3 @@ const hash = HashObject({ name: 'John', age: 30 });
## License

BSD-3-Clause

2 changes: 1 addition & 1 deletion libs/deep/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,5 @@
},
"type": "module",
"types": "./dist/index.d.ts",
"version": "4.3.2"
"version": "4.4.0"
}
93 changes: 93 additions & 0 deletions libs/deep/src/deepExtend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,96 @@ test('deepExtend - 13', () => {
a: null
});
});

test('deepExtend skips top-level __proto__ keys', () => {
removePollutedMarker();

try {
const payload = JSON.parse(
'{"__proto__":{"polluted":"pp"},"safe":"ok"}'
) as Record<string, unknown>;

const result = deepExtend({}, payload) as Record<string, unknown>;

expect(result.safe).toBe('ok');
expect(Object.hasOwn(result, '__proto__')).toBe(false);
expect(({} as any).polluted).toBeUndefined();
} finally {
removePollutedMarker();
}
});

test('deepExtend skips nested __proto__ keys', () => {
removePollutedMarker();

try {
const payload = JSON.parse(
'{"a":{"__proto__":{"polluted":"pp"},"safe":1}}'
) as any;

const result = deepExtend({ a: {} }, payload) as any;

expect(result.a.safe).toBe(1);
expect(Object.hasOwn(result.a, '__proto__')).toBe(false);
expect(({} as any).polluted).toBeUndefined();
} finally {
removePollutedMarker();
}
});

test('deepExtend skips constructor and prototype keys', () => {
removePollutedMarker();

try {
const payload = JSON.parse(
'{"constructor":{"prototype":{"polluted":true}},' +
'"prototype":{"polluted":true},"safe":"ok"}'
) as Record<string, unknown>;

const result = deepExtend({}, payload) as Record<string, unknown>;

expect(result.safe).toBe('ok');
expect(Object.hasOwn(result, 'constructor')).toBe(false);
expect(Object.hasOwn(result, 'prototype')).toBe(false);
expect(({} as any).polluted).toBeUndefined();
} finally {
removePollutedMarker();
}
});

test('deepExtend filters unsafe keys from a single source object', () => {
removePollutedMarker();

try {
const payload = JSON.parse(
'{"__proto__":{"polluted":"pp"},"safe":"ok"}'
) as Record<string, unknown>;

const result = deepExtend(payload) as Record<string, unknown>;

expect(result).not.toBe(payload);
expect(result.safe).toBe('ok');
expect(Object.hasOwn(result, '__proto__')).toBe(false);
expect(({} as any).polluted).toBeUndefined();
} finally {
removePollutedMarker();
}
});

test('deepExtend does not recurse into inherited target properties', () => {
const inheritedRetry = { maxRetries: 1 };
const options = Object.create({ retry: inheritedRetry });

const result = deepExtend(
{ options },
{ options: { retry: { minDelay: 10 } } }
) as any;

expect(inheritedRetry).toEqual({ maxRetries: 1 });
expect(result.options.retry).toEqual({ minDelay: 10 });
expect(Object.hasOwn(result.options, 'retry')).toBe(true);
});

function removePollutedMarker(): void {
delete (Object.prototype as { polluted?: unknown }).polluted;
}
63 changes: 44 additions & 19 deletions libs/deep/src/deepExtend.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
type UnsafeMergeKey = '__proto__' | 'constructor' | 'prototype';

type SafeMergeProps<T> = Omit<T, UnsafeMergeKey>;

type SafeProp<T, K extends PropertyKey> = K extends keyof SafeMergeProps<T>
? SafeMergeProps<T>[K]
: never;

/** Properties that exist in both `T1` and `T2`, typed as `T2`'s version. */
export type CommonProps<T1, T2> = {
[k in keyof T1 & keyof T2]: T1[k] extends never
[k in keyof SafeMergeProps<T1> & keyof SafeMergeProps<T2>]: SafeProp<
T1,
k
> extends never
? never
: T2[k] extends never
: SafeProp<T2, k> extends never
? never
: T2[k];
: SafeProp<T2, k>;
};

/** Properties present in `T1` but not in `T2`. */
export type PropsInFirstOnly<T1, T2> = Omit<T1, keyof T2>;
export type PropsInFirstOnly<T1, T2> = Omit<
SafeMergeProps<T1>,
keyof SafeMergeProps<T2>
>;

/** Recursively merges two object types. Matching keys are merged; unique keys are kept. */
export type MergeTwo<T1, T2> = PropsInFirstOnly<T1, T2> &
PropsInFirstOnly<T2, T1> & {
[k in keyof CommonProps<T1, T2>]: T1[k] extends Record<string, unknown>
? T2[k] extends Record<string, unknown>
? MergeTwo<T1[k], T2[k]>
: T2[k]
: T2[k];
[k in keyof CommonProps<T1, T2>]: SafeProp<T1, k> extends Record<
string,
unknown
>
? SafeProp<T2, k> extends Record<string, unknown>
? MergeTwo<SafeProp<T1, k>, SafeProp<T2, k>>
: SafeProp<T2, k>
: SafeProp<T2, k>;
};

/** Recursively merges a tuple of object types from left to right. */
Expand All @@ -26,14 +43,16 @@ export type Merge<T extends unknown[]> = T['length'] extends 3
: T['length'] extends 2
? MergeTwo<T[0], T[1]>
: T['length'] extends 1
? T[0]
? SafeMergeProps<T[0]>
: T extends [...infer K, infer PL, infer L]
? Merge<[Merge<[...K]>, MergeTwo<PL, L>]>
: never;

/**
* Deep-merges multiple objects into one. Later values override earlier ones;
* nested objects are merged recursively rather than replaced.
* Prototype-polluting keys (`__proto__`, `constructor`, and `prototype`) are
* ignored.
*
* @example
* ```ts
Expand All @@ -51,28 +70,30 @@ export const deepExtend = ((...rest) => {
if (rest.length === 0) throw new Error('no arguments');
if (typeof rest[0] !== 'object' || rest[0] === null)
throw new Error('not a non-null object');
if (rest.length === 1) return rest[0];

const result = {};

const extendObject = (o1: any, o2: any) => {
const keys = Object.keys(o2);

for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (isUnsafeMergeKey(key)) continue;

if (
!Reflect.has(o1, keys[i]) ||
!Object.hasOwn(o1, key) ||
!(
typeof o1[keys[i]] === 'object' &&
o1[keys[i]] !== null &&
typeof o2[keys[i]] === 'object'
typeof o1[key] === 'object' &&
o1[key] !== null &&
typeof o2[key] === 'object'
)
) {
o1[keys[i]] = o2[keys[i]];
o1[key] = o2[key];
} else {
if (o1[keys[i]] == null || o2[keys[i]] == null) {
o1[keys[i]] = o2[keys[i]];
if (o1[key] == null || o2[key] == null) {
o1[key] = o2[key];
} else {
extendObject(o1[keys[i]], o2[keys[i]]);
extendObject(o1[key], o2[key]);
}
}
}
Expand All @@ -88,3 +109,7 @@ export const deepExtend = ((...rest) => {

return result;
}) as <T extends unknown[]>(...args: T) => Merge<T>;

function isUnsafeMergeKey(key: string): boolean {
return key === '__proto__' || key === 'constructor' || key === 'prototype';
}
6 changes: 6 additions & 0 deletions libs/di/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @cleverbrush/di

## 4.4.0

### Patch Changes

- @cleverbrush/schema@4.4.0

## 4.3.2

### Patch Changes
Expand Down
4 changes: 2 additions & 2 deletions libs/di/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"email": "andrew_zol@cleverbrush.com"
},
"dependencies": {
"@cleverbrush/schema": "^4.3.2"
"@cleverbrush/schema": "^4.4.0"
},
"description": ".NET-style dependency injection container for TypeScript — schema-driven service registration, three lifetimes, function injection",
"files": [
Expand Down Expand Up @@ -43,5 +43,5 @@
},
"type": "module",
"types": "./dist/index.d.ts",
"version": "4.3.2"
"version": "4.4.0"
}
Loading
Loading