Files
NetMesh/electron/plugins/restrictedJsonSchema.cjs

164 lines
6.8 KiB
JavaScript
Raw Permalink Normal View History

"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,
};