Files
WRNexusJS/packages/security/src/object.ts
T
2026-08-01 10:04:42 +05:30

95 lines
2.7 KiB
TypeScript

import { SecurityError } from "./errors.ts";
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
export interface SafeObjectOptions {
maxDepth?: number;
maxKeys?: number;
allowInstances?: boolean;
}
export function isDangerousObjectKey(key: string): boolean {
return DANGEROUS_KEYS.has(key);
}
export function assertSafeObject(value: unknown, options: SafeObjectOptions = {}): void {
const maxDepth = options.maxDepth ?? 32;
const maxKeys = options.maxKeys ?? 10_000;
const seen = new WeakSet<object>();
let keyCount = 0;
const visit = (current: unknown, depth: number): void => {
if (current === null || typeof current !== "object") return;
if (depth > maxDepth) {
throw new SecurityError("WRN-SEC-OBJECT-DEPTH", `Object depth exceeds ${maxDepth}.`, 413);
}
if (seen.has(current)) {
throw new SecurityError("WRN-SEC-OBJECT-CYCLE", "Cyclic objects are not accepted.");
}
seen.add(current);
const proto = Object.getPrototypeOf(current);
if (
!options.allowInstances &&
!Array.isArray(current) &&
proto !== Object.prototype &&
proto !== null &&
!(current instanceof Date) &&
!(current instanceof URL)
) {
throw new SecurityError(
"WRN-SEC-OBJECT-PROTOTYPE",
"Only plain objects, arrays, dates, and URLs are accepted.",
);
}
for (const key of Object.keys(current)) {
keyCount++;
if (keyCount > maxKeys) {
throw new SecurityError(
"WRN-SEC-OBJECT-KEYS",
`Object contains more than ${maxKeys} keys.`,
413,
);
}
if (isDangerousObjectKey(key)) {
throw new SecurityError(
"WRN-SEC-PROTOTYPE-POLLUTION",
`Dangerous object key '${key}' is not allowed.`,
);
}
visit((current as Record<string, unknown>)[key], depth + 1);
}
seen.delete(current);
};
visit(value, 0);
}
export function safeMerge<T extends Record<string, unknown>>(
target: T,
...sources: Array<Record<string, unknown> | undefined | null>
): T {
for (const source of sources) {
if (!source) continue;
assertSafeObject(source);
for (const [key, value] of Object.entries(source)) {
if (isDangerousObjectKey(key)) continue;
const existing = target[key];
if (
value &&
existing &&
typeof value === "object" &&
typeof existing === "object" &&
!Array.isArray(value) &&
!Array.isArray(existing)
) {
safeMerge(existing as Record<string, unknown>, value as Record<string, unknown>);
} else {
target[key as keyof T] = value as T[keyof T];
}
}
}
return target;
}