/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ /* This file implements a not-quite standard JSON schema validator. It differs * from the spec in a few ways: * * - the spec doesn't allow custom types to be defined, but this validator * defines "URL", "URLorEmpty", "origin" etc. * - Strings are automatically converted to `URL` objects for the appropriate * types. * - It doesn't support "pattern" when matching strings. * - The boolean type accepts (and casts) 0 and 1 as valid values. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; XPCOMUtils.defineLazyGetter(lazy, "log", () => { let { ConsoleAPI } = ChromeUtils.importESModule( "resource://gre/modules/Console.sys.mjs" ); return new ConsoleAPI({ prefix: "JsonSchemaValidator.jsm", // tip: set maxLogLevel to "debug" and use log.debug() to create detailed // messages during development. See LOG_LEVELS in Console.sys.mjs for details. maxLogLevel: "error", }); }); /** * To validate a single value, use the static `JsonSchemaValidator.validate` * method. If you need to validate multiple values, you instead might want to * make a JsonSchemaValidator instance with the options you need and then call * the `validate` instance method. */ export class JsonSchemaValidator { /** * Validates a value against a schema. * * @param {*} value * The value to validate. * @param {object} schema * The schema to validate against. * @param {boolean} allowArrayNonMatchingItems * When true: * Invalid items in arrays will be ignored, and they won't be included in * result.parsedValue. * When false: * Invalid items in arrays will cause validation to fail. * @param {boolean} allowExplicitUndefinedProperties * When true: * `someProperty: undefined` will be allowed for non-required properties. * When false: * `someProperty: undefined` will cause validation to fail even for * properties that are not required. * @param {boolean} allowNullAsUndefinedProperties * When true: * `someProperty: null` will be allowed for non-required properties whose * expected types are non-null. * When false: * `someProperty: null` will cause validation to fail for non-required * properties, except for properties whose expected types are null. * @param {boolean} allowExtraProperties * When true: * Properties that are not defined in the schema will be ignored, and they * won't be included in result.parsedValue. * When false: * Properties that are not defined in the schema will cause validation to * fail. * @return {object} * The result of the validation, an object that looks like this: * * { * valid, * parsedValue, * error: { * message, * rootValue, * rootSchema, * invalidValue, * invalidPropertyNameComponents, * } * } * * {boolean} valid * True if validation is successful, false if not. * {*} parsedValue * If validation is successful, this is the validated value. It can * differ from the passed-in value in the following ways: * * If a type in the schema is "URL" or "URLorEmpty", the passed-in * value can use a string instead and it will be converted into a * `URL` object in parsedValue. * * Some of the `allow*` parameters control the properties that appear. * See above. * {Error} error * If validation fails, `error` will be present. It contains a number of * properties useful for understanding the validation failure. * {string} error.message * The validation failure message. * {*} error.rootValue * The passed-in value. * {object} error.rootSchema * The passed-in schema. * {*} invalidValue * The value that caused validation to fail. If the passed-in value is a * scalar type, this will be the value itself. If the value is an object * or array, it will be the specific nested value in the object or array * that caused validation to fail. * {array} invalidPropertyNameComponents * If the passed-in value is an object or array, this will contain the * names of the object properties or array indexes where invalidValue can * be found. For example, assume the passed-in value is: * { foo: { bar: { baz: 123 }}} * And assume `baz` should be a string instead of a number. Then * invalidValue will be 123, and invalidPropertyNameComponents will be * ["foo", "bar", "baz"], indicating that the erroneous property in the * passed-in object is `foo.bar.baz`. */ static validate( value, schema, { allowArrayNonMatchingItems = false, allowExplicitUndefinedProperties = false, allowNullAsUndefinedProperties = false, allowExtraProperties = false, } = {} ) { let validator = new JsonSchemaValidator({ allowArrayNonMatchingItems, allowExplicitUndefinedProperties, allowNullAsUndefinedProperties, allowExtraProperties, }); return validator.validate(value, schema); } /** * Constructor. * * @param {boolean} allowArrayNonMatchingItems * See the static `validate` method above. * @param {boolean} allowExplicitUndefinedProperties * See the static `validate` method above. * @param {boolean} allowNullAsUndefinedProperties * See the static `validate` method above. * @param {boolean} allowExtraProperties * See the static `validate` method above. */ constructor({ allowArrayNonMatchingItems = false, allowExplicitUndefinedProperties = false, allowNullAsUndefinedProperties = false, allowExtraProperties = false, } = {}) { this.allowArrayNonMatchingItems = allowArrayNonMatchingItems; this.allowExplicitUndefinedProperties = allowExplicitUndefinedProperties; this.allowNullAsUndefinedProperties = allowNullAsUndefinedProperties; this.allowExtraProperties = allowExtraProperties; } /** * Validates a value against a schema. * * @param {*} value * The value to validate. * @param {object} schema * The schema to validate against. * @return {object} * The result object. See the static `validate` method above. */ validate(value, schema) { return this._validateRecursive(value, schema, [], { rootValue: value, rootSchema: schema, }); } // eslint-disable-next-line complexity _validateRecursive(param, properties, keyPath, state) { lazy.log.debug(`checking @${param}@ for type ${properties.type}`); if (Array.isArray(properties.type)) { lazy.log.debug("type is an array"); // For an array of types, the value is valid if it matches any of the // listed types. To check this, make versions of the object definition // that include only one type at a time, and check the value against each // one. for (const type of properties.type) { let typeProperties = Object.assign({}, properties, { type }); lazy.log.debug(`checking subtype ${type}`); let result = this._validateRecursive( param, typeProperties, keyPath, state ); if (result.valid) { return result; } } // None of the types matched return { valid: false, error: new JsonSchemaValidatorError({ message: `The value '${valueToString(param)}' does not match any type in ` + valueToString(properties.type), value: param, keyPath, state, }), }; } switch (properties.type) { case "boolean": case "number": case "integer": case "string": case "URL": case "URLorEmpty": case "origin": case "null": { let result = this._validateSimpleParam( param, properties.type, keyPath, state ); if (!result.valid) { return result; } if (properties.enum && typeof result.parsedValue !== "boolean") { if (!properties.enum.includes(param)) { return { valid: false, error: new JsonSchemaValidatorError({ message: `The value '${valueToString(param)}' is not one of the ` + `enumerated values ${valueToString(properties.enum)}`, value: param, keyPath, state, }), }; } } return result; } case "array": if (!Array.isArray(param)) { return { valid: false, error: new JsonSchemaValidatorError({ message: `The value '${valueToString(param)}' does not match the ` + `expected type 'array'`, value: param, keyPath, state, }), }; } let parsedArray = []; for (let i = 0; i < param.length; i++) { let item = param[i]; lazy.log.debug( `in array, checking @${item}@ for type ${properties.items.type}` ); let result = this._validateRecursive( item, properties.items, keyPath.concat(i), state ); if (!result.valid) { if ( ("strict" in properties && properties.strict) || (!("strict" in properties) && !this.allowArrayNonMatchingItems) ) { return result; } continue; } parsedArray.push(result.parsedValue); } return { valid: true, parsedValue: parsedArray }; case "object": { if (typeof param != "object" || !param) { return { valid: false, error: new JsonSchemaValidatorError({ message: `The value '${valueToString(param)}' does not match the ` + `expected type 'object'`, value: param, keyPath, state, }), }; } let parsedObj = {}; let patternProperties = []; if ("patternProperties" in properties) { for (let prop of Object.keys(properties.patternProperties || {})) { let pattern; try { pattern = new RegExp(prop); } catch (e) { throw new Error( `Internal error: Invalid property pattern ${prop}` ); } patternProperties.push({ pattern, schema: properties.patternProperties[prop], }); } } if (properties.required) { for (let required of properties.required) { if (!(required in param)) { lazy.log.error(`Object is missing required property ${required}`); return { valid: false, error: new JsonSchemaValidatorError({ message: `Object is missing required property '${required}'`, value: param, keyPath, state, }), }; } } } for (let item of Object.keys(param)) { let schema; if ( "properties" in properties && properties.properties.hasOwnProperty(item) ) { schema = properties.properties[item]; } else if (patternProperties.length) { for (let patternProperty of patternProperties) { if (patternProperty.pattern.test(item)) { schema = patternProperty.schema; break; } } } if (!schema) { let allowExtraProperties = !properties.strict && this.allowExtraProperties; if (allowExtraProperties) { continue; } return { valid: false, error: new JsonSchemaValidatorError({ message: `Object has unexpected property '${item}'`, value: param, keyPath, state, }), }; } let allowExplicitUndefinedProperties = !properties.strict && this.allowExplicitUndefinedProperties; let allowNullAsUndefinedProperties = !properties.strict && this.allowNullAsUndefinedProperties; let isUndefined = (!allowExplicitUndefinedProperties && !(item in param)) || (allowExplicitUndefinedProperties && param[item] === undefined) || (allowNullAsUndefinedProperties && param[item] === null); if (isUndefined) { continue; } let result = this._validateRecursive( param[item], schema, keyPath.concat(item), state ); if (!result.valid) { return result; } parsedObj[item] = result.parsedValue; } return { valid: true, parsedValue: parsedObj }; } case "JSON": if (typeof param == "object") { return { valid: true, parsedValue: param }; } try { let json = JSON.parse(param); if (typeof json != "object") { return { valid: false, error: new JsonSchemaValidatorError({ message: `JSON was not an object: ${valueToString(param)}`, value: param, keyPath, state, }), }; } return { valid: true, parsedValue: json }; } catch (e) { lazy.log.error("JSON string couldn't be parsed"); return { valid: false, error: new JsonSchemaValidatorError({ message: `JSON string could not be parsed: ${valueToString( param )}`, value: param, keyPath, state, }), }; } } return { valid: false, error: new JsonSchemaValidatorError({ message: `Invalid schema property type: ${valueToString( properties.type )}`, value: param, keyPath, state, }), }; } _validateSimpleParam(param, type, keyPath, state) { let valid = false; let parsedParam = param; let error = undefined; switch (type) { case "boolean": if (typeof param == "boolean") { valid = true; } else if (typeof param == "number" && (param == 0 || param == 1)) { valid = true; parsedParam = !!param; } break; case "number": case "string": valid = typeof param == type; break; // integer is an alias to "number" that some JSON schema tools use case "integer": valid = typeof param == "number"; break; case "null": valid = param === null; break; case "origin": if (typeof param != "string") { break; } try { parsedParam = new URL(param); if (parsedParam.protocol == "file:") { // Treat the entire file URL as an origin. // Note this is stricter than the current Firefox policy, // but consistent with Chrome. // See https://bugzilla.mozilla.org/show_bug.cgi?id=803143 valid = true; } else { let pathQueryRef = parsedParam.pathname + parsedParam.hash; // Make sure that "origin" types won't accept full URLs. if (pathQueryRef != "/" && pathQueryRef != "") { lazy.log.error( `Ignoring parameter "${param}" - origin was expected but received full URL.` ); valid = false; } else { valid = true; } } } catch (ex) { lazy.log.error(`Ignoring parameter "${param}" - not a valid origin.`); valid = false; } break; case "URL": case "URLorEmpty": if (typeof param != "string") { break; } if (type == "URLorEmpty" && param === "") { valid = true; break; } try { parsedParam = new URL(param); valid = true; } catch (ex) { if (!param.startsWith("http")) { lazy.log.error( `Ignoring parameter "${param}" - scheme (http or https) must be specified.` ); } valid = false; } break; } if (!valid && !error) { error = new JsonSchemaValidatorError({ message: `The value '${valueToString(param)}' does not match the expected ` + `type '${type}'`, value: param, keyPath, state, }); } let result = { valid, parsedValue: parsedParam, }; if (error) { result.error = error; } return result; } } class JsonSchemaValidatorError extends Error { constructor({ message, value, keyPath, state } = {}, ...args) { if (keyPath.length) { message += ". " + `The invalid value is property '${keyPath.join(".")}' in ` + JSON.stringify(state.rootValue); } super(message, ...args); this.name = "JsonSchemaValidatorError"; this.rootValue = state.rootValue; this.rootSchema = state.rootSchema; this.invalidPropertyNameComponents = keyPath; this.invalidValue = value; } } function valueToString(value) { try { return JSON.stringify(value); } catch (ex) {} return String(value); }