"use strict"; const Ajv2020 = require("ajv/dist/2020").default; const MAX_SCHEMA_DEPTH = 8; const MAX_SCHEMA_NODES = 256; const MAX_ENUM_ITEMS = 256; const MAX_PROPERTY_NAME_LENGTH = 128; const FORBIDDEN_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]); const ALLOWED_TYPES = new Set(["array", "boolean", "integer", "null", "number", "object", "string"]); const ALLOWED_KEYWORDS = new Set([ "additionalProperties", "const", "enum", "items", "maxItems", "maxLength", "maximum", "minItems", "minLength", "minimum", "properties", "required", "type", "writeOnly", ]); const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false, }); function isPlainRecord(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function assertSafeName(name, label) { if (typeof name !== "string" || name.length < 1 || name.length > MAX_PROPERTY_NAME_LENGTH || FORBIDDEN_PROPERTY_NAMES.has(name) || name.includes("\0")) { throw new TypeError(`${label} is invalid`); } } function assertIntegerKeyword(schema, name) { if (schema[name] === undefined) return; if (!Number.isSafeInteger(schema[name]) || schema[name] < 0) { throw new TypeError(`Restricted setting schema ${name} must be a non-negative safe integer`); } } function assertNumberKeyword(schema, name) { if (schema[name] === undefined) return; if (typeof schema[name] !== "number" || !Number.isFinite(schema[name])) { throw new TypeError(`Restricted setting schema ${name} must be finite`); } } function assertRestrictedJsonSchema(root, options = {}) { const rootType = options.rootType ?? null; const stack = [{ schema: root, depth: 0, label: "valueSchema" }]; let nodes = 0; while (stack.length) { const current = stack.pop(); if (!current || !isPlainRecord(current.schema)) { throw new TypeError("Restricted setting schemas must contain plain objects"); } nodes += 1; if (nodes > MAX_SCHEMA_NODES || current.depth > MAX_SCHEMA_DEPTH) { throw new TypeError("Restricted setting schema is too complex"); } const schema = current.schema; for (const keyword of Object.keys(schema)) { if (!ALLOWED_KEYWORDS.has(keyword)) { throw new TypeError(`Restricted setting schema keyword is not allowed: ${keyword}`); } } if (!ALLOWED_TYPES.has(schema.type)) { throw new TypeError("Every restricted setting schema node must declare one supported type"); } if (current.depth === 0 && rootType && schema.type !== rootType) { throw new TypeError(`Restricted setting schema root must use type ${rootType}`); } if (schema.enum !== undefined) { if (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > MAX_ENUM_ITEMS) { throw new TypeError("Restricted setting schema enum is invalid"); } } assertIntegerKeyword(schema, "minItems"); assertIntegerKeyword(schema, "maxItems"); assertIntegerKeyword(schema, "minLength"); assertIntegerKeyword(schema, "maxLength"); assertNumberKeyword(schema, "minimum"); assertNumberKeyword(schema, "maximum"); if (schema.minItems !== undefined && schema.maxItems !== undefined && schema.minItems > schema.maxItems) { throw new TypeError("Restricted setting schema minItems exceeds maxItems"); } if (schema.minLength !== undefined && schema.maxLength !== undefined && schema.minLength > schema.maxLength) { throw new TypeError("Restricted setting schema minLength exceeds maxLength"); } if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum) { throw new TypeError("Restricted setting schema minimum exceeds maximum"); } if (schema.type === "integer" && schema.minimum !== undefined && schema.maximum !== undefined && Math.ceil(schema.minimum) > Math.floor(schema.maximum)) { throw new TypeError("Restricted integer setting schema has no valid value"); } if (schema.type === "array") { if (!isPlainRecord(schema.items)) throw new TypeError("Restricted array schemas require one items schema"); stack.push({ schema: schema.items, depth: current.depth + 1, label: `${current.label}.items` }); } else if (schema.items !== undefined || schema.minItems !== undefined || schema.maxItems !== undefined) { throw new TypeError("Restricted setting schema array keywords require type array"); } if (schema.type === "object") { if (!isPlainRecord(schema.properties)) throw new TypeError("Restricted object schemas require properties"); if (schema.additionalProperties !== false) { throw new TypeError("Restricted object schemas must set additionalProperties to false"); } const propertyNames = Object.keys(schema.properties); for (const name of propertyNames) { assertSafeName(name, "Restricted setting schema property name"); stack.push({ schema: schema.properties[name], depth: current.depth + 1, label: `${current.label}.${name}` }); } if (schema.required !== undefined) { if (!Array.isArray(schema.required) || new Set(schema.required).size !== schema.required.length || schema.required.some((name) => typeof name !== "string" || !Object.hasOwn(schema.properties, name))) { throw new TypeError("Restricted object schema required fields are invalid"); } } } else if (schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined) { throw new TypeError("Restricted setting schema object keywords require type object"); } if (schema.type !== "string" && (schema.minLength !== undefined || schema.maxLength !== undefined)) { throw new TypeError("Restricted setting schema string keywords require type string"); } if (!["integer", "number"].includes(schema.type) && (schema.minimum !== undefined || schema.maximum !== undefined)) { throw new TypeError("Restricted setting schema numeric keywords require type number or integer"); } } return root; } function compileRestrictedJsonSchema(schema, options = {}) { assertRestrictedJsonSchema(schema, options); let validate; try { validate = ajv.compile(schema); } catch (error) { throw new TypeError(`Restricted setting schema is invalid: ${error?.message ?? error}`); } return (value) => { if (validate(value)) return true; const details = ajv.errorsText(validate.errors, { separator: "; " }); throw new TypeError(`Value does not match its restricted setting schema: ${details}`); }; } module.exports = { ALLOWED_KEYWORDS, MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES, assertRestrictedJsonSchema, compileRestrictedJsonSchema, };