95 lines
2.7 KiB
TypeScript
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;
|
|
}
|