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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-commit-messages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"url": "https://github.com/webdeveric/utils/issues"
},
"homepage": "https://github.com/webdeveric/utils/#readme",
"packageManager": "pnpm@11.19.0+sha512.7881f3ed590d472c4a955e2b88b2121791116066dcc88cbca3849ec9b60f1bbaa6d2ccb221fa91da4e1c65bef2bcbe379365aea7ac539c7bf86dedc3a1b22dce",
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
"scripts": {
"clean": "rimraf ./dist/ ./cache/ ./coverage/",
"prebuild": "pnpm clean",
Expand All @@ -86,15 +86,15 @@
},
"prettier": "@webdeveric/prettier-config",
"devDependencies": {
"@commitlint/config-conventional": "^21.2.0",
"@commitlint/config-conventional": "^21.2.2",
"@commitlint/types": "^21.2.0",
"@types/node": "^24.13.3",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/coverage-v8": "^4.1.11",
"@webdeveric/eslint-config-ts": "^0.12.1",
"@webdeveric/prettier-config": "^0.4.0",
"@webdeveric/ts-data-structures": "^1.0.1",
"commitlint": "^21.2.1",
"commitlint": "^21.2.2",
"commitlint-plugin-cspell": "^0.9.4",
"conventional-changelog-conventionalcommits": "^9.3.1",
"cspell": "^10.0.1",
Expand All @@ -107,10 +107,10 @@
"lint-staged": "^17.3.0",
"prettier": "^3.9.6",
"rimraf": "^6.1.3",
"semantic-release": "^25.0.8",
"semantic-release": "^25.0.9",
"typedoc": "^0.28.20",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"validate-package-exports": "^1.3.3",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
}
}
781 changes: 388 additions & 393 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion src/codePointLength.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,12 @@
* ```
*/
export function codePointLength(input: string): number {
return [...input].length;
let count = 0;

// eslint-disable-next-line @typescript-eslint/naming-convention
for (const _ of input) {
count++;
}

return count;
}
4 changes: 1 addition & 3 deletions src/combinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ export function* combinations<
const propertyNames = Object.keys(input);
const propertyValues = Object.values(input).map((value) =>
isIterable(value)
? Array.from(value, (item) => {
return isObject(item) ? [...combinations(item)] : asArray(item);
}).flat()
? Array.from(value).flatMap((item) => (isObject(item) ? [...combinations(item)] : asArray(item)))
: isObject(value)
? [...combinations(value)]
: asArray(value),
Expand Down
11 changes: 7 additions & 4 deletions src/convert.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getOwnProperties } from './getOwnProperties.js';
import { getType } from './getType.js';
import { isPrototypePollutionPropertyKey } from './predicate/isPrototypePollutionPropertyKey.js';

import type { Builtin } from './types/common.js';

Expand Down Expand Up @@ -27,11 +28,13 @@ export const convert = <Input, Output>(input: Readonly<Input>, converter: AnyCon
}

if (typeof converter === 'object' && converter !== null) {
return getOwnProperties(converter).reduce<Partial<Output>>((data, key) => {
data[key] = convert(input, converter[key]);
return getOwnProperties(converter)
.filter((key) => !isPrototypePollutionPropertyKey(key))
.reduce<Partial<Output>>((data, key) => {
data[key] = convert(input, converter[key]);

return data;
}, {}) as Output;
return data;
}, {}) as Output;
}

throw new TypeError(`${getType(converter)} is not a valid converter`);
Expand Down
2 changes: 1 addition & 1 deletion src/deepFreeze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function deepFreeze<Input extends object>(
partialOptions: Partial<DeepFreezeOptions> = {},
): Readonly<Input> {
const {
filterProperties = new Set<PropertyKey>(['prototype', '__proto__']),
filterProperties = new Set<PropertyKey>(['__proto__', 'constructor', 'prototype']),
doNotFreeze = new WeakSet([globalThis]),
frozen = new WeakSet(),
} = partialOptions;
Expand Down
11 changes: 6 additions & 5 deletions src/findIndentation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { INDENT_CHAR_RAW_PATTERN, NEWLINE_RAW_PATTERN } from './constants.js';

const blankLinePattern = /^\s*$/;
const leadingWhitespacePattern = new RegExp(
String.raw`^(?:${NEWLINE_RAW_PATTERN})?(?<whiteSpace>${INDENT_CHAR_RAW_PATTERN}+)`,
);

/**
* Find indentation on first line that isn't whitespace only.
*
Expand All @@ -9,9 +14,5 @@ import { INDENT_CHAR_RAW_PATTERN, NEWLINE_RAW_PATTERN } from './constants.js';
* ```
*/
export function findIndentation(lines: readonly string[]): string | undefined {
return lines
.find((line) => !/^\s*$/.test(line))
?.match(new RegExp(String.raw`^(?:${NEWLINE_RAW_PATTERN})?(?<whiteSpace>${INDENT_CHAR_RAW_PATTERN}+)`))?.groups?.[
'whiteSpace'
];
return lines.find((line) => !blankLinePattern.test(line))?.match(leadingWhitespacePattern)?.groups?.['whiteSpace'];
}
4 changes: 3 additions & 1 deletion src/getOwnPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export const getOwnPaths = <Type extends AnyRecord>(input: Type): Path<Type>[] =
paths.push(key);

if (value && typeof value === 'object') {
paths.push(...getOwnPaths(value).map((childKey) => `${key}.${childKey}`));
for (const childKey of getOwnPaths(value)) {
paths.push(`${key}.${childKey}`);
}
}

return paths;
Expand Down
4 changes: 3 additions & 1 deletion src/getPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export const getPaths = <Type extends AnyRecord>(input: Type): Path<Type>[] => {
const value = input[key];

if (value && typeof value === 'object') {
paths.push(...getPaths(value).map((childKey) => `${key}.${childKey}`));
for (const childKey of getPaths(value)) {
paths.push(`${key}.${childKey}`);
}
}
}

Expand Down
7 changes: 3 additions & 4 deletions src/indent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { NEWLINE_RAW_PATTERN } from './constants.js';

const newLinePattern = new RegExp(String.raw`(?<newLine>${NEWLINE_RAW_PATTERN})`, 'g');

/**
* Add `indent` before each line in `input`.
*
Expand All @@ -9,8 +11,5 @@ import { NEWLINE_RAW_PATTERN } from './constants.js';
* ```
*/
export function indent(input: string, indentation: string): string {
return (
indentation +
input.replaceAll(new RegExp(String.raw`(?<newLine>${NEWLINE_RAW_PATTERN})`, 'g'), `$<newLine>${indentation}`)
);
return indentation + input.replaceAll(newLinePattern, `$<newLine>${indentation}`);
}
27 changes: 15 additions & 12 deletions src/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getOwnProperties } from './getOwnProperties.js';
import { isObject } from './predicate/isObject.js';
import { isPrototypePollutionPropertyKey } from './predicate/isPrototypePollutionPropertyKey.js';

import type { Builtin } from './types/common.js';
import type { AnyRecord } from './types/records.js';
Expand Down Expand Up @@ -78,18 +79,20 @@ export function normalize<Data extends object, ContextData extends AnyRecord = A
}

if (isObject(currentNormalizers) && isObject(currentRecord)) {
return getOwnProperties(currentNormalizers).reduce<CurrentRecord>((data, key) => {
const innerNormalizers = currentNormalizers[key];
// If the value is undefined but there is a normalizer record, set value to empty object.
const value =
typeof data[key] === 'undefined' && isObject(innerNormalizers)
? ({} as unknown as CurrentRecord[keyof CurrentRecord])
: data[key];

data[key] = walk(value, innerNormalizers);

return data;
}, currentRecord);
return getOwnProperties(currentNormalizers)
.filter((key) => !isPrototypePollutionPropertyKey(key))
.reduce<CurrentRecord>((data, key) => {
const innerNormalizers = currentNormalizers[key];
// If the value is undefined but there is a normalizer record, set value to empty object.
const value =
typeof data[key] === 'undefined' && isObject(innerNormalizers)
? ({} as unknown as CurrentRecord[keyof CurrentRecord])
: data[key];

data[key] = walk(value, innerNormalizers);

return data;
}, currentRecord);
}

return currentRecord;
Expand Down
6 changes: 3 additions & 3 deletions src/pathParts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const wordPattern = /\w+/g;

/**
* Yield each part of `input`, splitting a string path on non-word characters.
*
Expand All @@ -17,9 +19,7 @@ export function* pathParts(input: PropertyKey): Generator<PropertyKey> {
return;
}

const regexp = /\w+/g;

const iterator = input.matchAll(regexp);
const iterator = input.matchAll(wordPattern);

for (const part of iterator) {
yield part[0];
Expand Down
5 changes: 5 additions & 0 deletions src/predicate/isPrototypePollutionPropertyKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type PrototypePollutionPropertyKey = '__proto__' | 'constructor' | 'prototype';

export function isPrototypePollutionPropertyKey(key: unknown): key is PrototypePollutionPropertyKey {
return typeof key === 'string' && (key === '__proto__' || key === 'constructor' || key === 'prototype');
}
4 changes: 3 additions & 1 deletion src/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { pathParts } from './pathParts.js';

import type { Path, PathValue } from './types/objects.js';

const prototypePollutionPattern = /\b(__proto__|constructor|prototype)\b/;

export function set<Input extends object, InputPath extends Path<Input>, Value extends PathValue<Input, InputPath>>(
input: Input,
path: InputPath,
Expand Down Expand Up @@ -34,7 +36,7 @@ export function set<Input extends object, InputPath extends Path<Input> | string
throw new Error('Path cannot be an empty string');
}

if (typeof path === 'string' && /\b(__proto__|constructor|prototype)\b/.test(path)) {
if (typeof path === 'string' && prototypePollutionPattern.test(path)) {
throw new Error('Cannot pollute prototype');
}

Expand Down
4 changes: 3 additions & 1 deletion src/trimIndentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { NEWLINE_RAW_PATTERN } from './constants.js';
import { findIndentation } from './findIndentation.js';
import { stripIndent } from './stripIndent.js';

const newLinePattern = new RegExp(NEWLINE_RAW_PATTERN);

/**
* Remove common leading indentation from every line in `input` and trim the result.
*
Expand All @@ -11,7 +13,7 @@ import { stripIndent } from './stripIndent.js';
* ```
*/
export function trimIndentation(input: string): string {
const whiteSpace = findIndentation(input.split(new RegExp(NEWLINE_RAW_PATTERN)));
const whiteSpace = findIndentation(input.split(newLinePattern));

return (whiteSpace ? stripIndent(input, whiteSpace) : input).trim();
}
40 changes: 38 additions & 2 deletions test/convert.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';

import { convert, type ConvertFn } from '../src/convert.js';
import { convert, type AnyConverter, type ConvertFn } from '../src/convert.js';

describe('convert()', () => {
it('Converts from one type to another using a function', () => {
Expand Down Expand Up @@ -97,4 +97,40 @@ describe('convert()', () => {
convert(false, null as unknown as ConvertFn<unknown, unknown>);
}).toThrow(TypeError);
});

describe('Prototype pollution protection', () => {
afterEach(() => {
// Guard other tests in case an assertion fails before pollution is verified absent.
Reflect.deleteProperty(Object.prototype, 'polluted');
});

it('Ignores a "__proto__" key so it cannot reach Object.prototype', () => {
const maliciousConverter = {
name: (input: { name: string }) => input.name,
['__proto__']: {
polluted: () => 'polluted',
},
};

const result = convert({ name: 'Test' }, maliciousConverter);

expect(Object.prototype).not.toHaveProperty('polluted');
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
});

it('Never invokes converters keyed by "constructor" or "prototype"', () => {
const constructorConverter = vi.fn(() => 'polluted');
const prototypeConverter = vi.fn(() => 'polluted');

const maliciousConverter = {
['constructor']: constructorConverter,
['prototype']: prototypeConverter,
};

convert({}, maliciousConverter as unknown as AnyConverter<unknown, unknown>);

expect(constructorConverter).not.toHaveBeenCalled();
expect(prototypeConverter).not.toHaveBeenCalled();
});
});
});
39 changes: 37 additions & 2 deletions test/normalize.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach, vi } from 'vitest';

import { normalize, type NormalizerFn } from '../src/normalize.js';
import { normalize, type AnyNormalizer, type NormalizerFn } from '../src/normalize.js';

type Person = {
name: string;
Expand Down Expand Up @@ -85,6 +85,41 @@ describe('normalize()', () => {
expect(normalize(person, null as unknown as NormalizerFn<Person, Person>)).toEqual(person);
});

describe('Prototype pollution protection', () => {
afterEach(() => {
// Guard other tests in case an assertion fails before pollution is verified absent.
Reflect.deleteProperty(Object.prototype, 'polluted');
});

it('Ignores a "__proto__" key so it cannot reach Object.prototype', () => {
const maliciousNormalizers = {
name: (name: string) => name,
['__proto__']: {
polluted: () => 'polluted',
},
};

normalize(person, maliciousNormalizers);

expect(Object.prototype).not.toHaveProperty('polluted');
});

it('Never invokes normalizers keyed by "constructor" or "prototype"', () => {
const constructorNormalizer = vi.fn(() => ({ polluted: () => 'polluted' }));
const prototypeNormalizer = vi.fn(() => ({ polluted: () => 'polluted' }));

const maliciousNormalizers = {
['constructor']: constructorNormalizer,
['prototype']: prototypeNormalizer,
};

normalize(person, maliciousNormalizers as unknown as AnyNormalizer<Person, Person>);

expect(constructorNormalizer).not.toHaveBeenCalled();
expect(prototypeNormalizer).not.toHaveBeenCalled();
});
});

describe('NormalizeFn', () => {
it('Receives two arguments', () => {
normalize(person, {
Expand Down