Source code

Revision control

Copy as Markdown

Other Tools

/* 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 module validates enterprise policy parameters against the policy schema.
*
* Policy data arrives from several sources (policies.json, Windows GPO, macOS
* config profiles) in shapes that differ from clean JSON. This module turns it
* into validated, ready-to-use data in three stages:
*
* 1. NORMALIZE: transform source-specific quirks into standard JSON so the
* value can be validated by an off-the-shelf validator. Booleans delivered
* as 0/1 integers become real booleans; values delivered as a JSON string
* (fields marked "contentMediaType": "application/json") are parsed into
* objects/arrays. No validity decisions happen here.
* 2. VALIDATE: the compliant validator in JsonSchema.sys.mjs checks the
* normalized value using only standard JSON Schema keywords (type, format,
* pattern, enum, required). Every list entry is validated individually:
* invalid entries are dropped (and logged) so one bad entry never discards
* the whole list. A bad non-list value fails its policy.
* 3. HYDRATE: validated "format": "uri" strings are turned into URL objects,
* which the policy implementations consume (.href, .hostname, etc.).
*
* The schema itself stays standard JSON Schema, so the same file can drive
* other tooling (e.g. the enterprise console). "contentMediaType" is the only
* keyword this module keys on beyond standard validation.
*/
const lazy = {};
ChromeUtils.defineLazyGetter(lazy, "JsonSchema", () => {
return ChromeUtils.importESModule("resource://gre/modules/JsonSchema.sys.mjs")
.JsonSchema;
});
ChromeUtils.defineLazyGetter(lazy, "log", () => {
let { ConsoleAPI } = ChromeUtils.importESModule(
"resource://gre/modules/Console.sys.mjs"
);
return new ConsoleAPI({
prefix: "PolicySchemaValidator",
maxLogLevel: "error",
});
});
const JSON_MEDIA_TYPE = "application/json";
/**
* Validate (and coerce) a policy parameter value against a policy schema.
*
* @param {any} value
* The value to validate, as provided by a policy source (policies.json, GPO,
* or macOS config profile).
* @param {object} schema
* The schema for this policy, from policies-schema.json.
* @param {object} [options]
* @param {boolean} [options.allowAdditionalProperties]
* When true, object properties not described by the schema are stripped from
* the parsed value. When false, they cause validation to fail.
* @returns {{valid: boolean, parsedValue?: any, error?: Error}}
* On success, `parsedValue` holds the normalized and hydrated value (URL
* objects for uri-formatted fields, parsed objects for JSON-string fields,
* booleans for 0/1, and lists with invalid entries removed).
*/
export function validate(
value,
schema,
{ allowAdditionalProperties = false } = {}
) {
const normalized = trimInvalidListItems(normalize(value, schema), schema);
const { valid, errors } = lazy.JsonSchema.validate(normalized, schema);
if (!valid) {
return { valid: false, error: new Error(formatErrors(errors)) };
}
try {
return {
valid: true,
parsedValue: hydrate(normalized, schema, allowAdditionalProperties),
};
} catch (ex) {
if (ex instanceof PolicyParameterError) {
return { valid: false, error: ex };
}
throw ex;
}
}
/**
* Signals an object carries a property the schema does not describe while
* additional properties are disallowed.
*/
class PolicyParameterError extends Error {
constructor(message) {
super(message);
this.name = "PolicyParameterError";
}
}
function formatErrors(errors) {
return errors.map(e => `${e.error} (at ${e.instanceLocation})`).join("; ");
}
function valueToString(value) {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function effectiveTypes(schema) {
let types = [];
if (Array.isArray(schema.type)) {
types.push(...schema.type);
} else if (schema.type !== undefined) {
types.push(schema.type);
}
// A "boolean or enumerated string" policy is expressed with anyOf; gather the
// branch types too so a 0/1 boolean (as GPO/macOS deliver it) is coerced.
if (Array.isArray(schema.anyOf)) {
for (let branch of schema.anyOf) {
if (Array.isArray(branch.type)) {
types.push(...branch.type);
} else if (branch.type !== undefined) {
types.push(branch.type);
}
}
}
return types;
}
function subschemaForProperty(schema, key) {
if (schema.properties && Object.hasOwn(schema.properties, key)) {
return schema.properties[key];
}
if (schema.patternProperties) {
for (const pattern of Object.keys(schema.patternProperties)) {
if (new RegExp(pattern).test(key)) {
return schema.patternProperties[pattern];
}
}
}
return undefined;
}
function isUriSchema(schema) {
if (schema.format === "uri") {
return true;
}
if (Array.isArray(schema.anyOf)) {
return schema.anyOf.some(branch => branch.format === "uri");
}
return false;
}
/*
* Stage 1. Transform source-specific shapes into standard JSON. Makes no
* validity decisions; values it cannot transform are left as-is for the
* validator to judge.
*/
function normalize(value, schema) {
if (!schema || typeof schema != "object") {
return value;
}
if (schema.contentMediaType === JSON_MEDIA_TYPE && typeof value == "string") {
try {
value = JSON.parse(value);
} catch {
return value;
}
}
const types = effectiveTypes(schema);
// GPO (REG_DWORD) and macOS (NSNumber) deliver booleans as 0/1. Only coerce
// when the field is boolean and not also numeric, otherwise a numeric field
// (e.g. a Preferences value typed number|boolean|string) would lose its
// integer value.
if (
types.includes("boolean") &&
!types.includes("number") &&
!types.includes("integer") &&
typeof value == "number" &&
(value === 0 || value === 1)
) {
return !!value;
}
if (value && typeof value == "object" && !Array.isArray(value)) {
if (schema.properties || schema.patternProperties) {
const result = {};
for (const key of Object.keys(value)) {
const subschema = subschemaForProperty(schema, key);
result[key] = subschema ? normalize(value[key], subschema) : value[key];
}
return result;
}
return value;
}
if (Array.isArray(value) && schema.items) {
return value.map(item => normalize(item, schema.items));
}
return value;
}
/*
* Stage 2 helper. Validate each list entry on its own and keep only the valid
* ones, so a single bad entry is dropped (and logged) rather than failing the
* whole policy. Recurses through objects and nested lists.
*/
function trimInvalidListItems(value, schema) {
if (!schema || typeof schema != "object" || value == null) {
return value;
}
if (typeof value == "object" && !Array.isArray(value)) {
if (schema.properties || schema.patternProperties) {
const result = {};
for (const key of Object.keys(value)) {
const subschema = subschemaForProperty(schema, key);
result[key] = subschema
? trimInvalidListItems(value[key], subschema)
: value[key];
}
return result;
}
return value;
}
if (Array.isArray(value) && schema.items) {
const result = [];
for (const item of value) {
const trimmedItem = trimInvalidListItems(item, schema.items);
if (lazy.JsonSchema.validate(trimmedItem, schema.items).valid) {
result.push(trimmedItem);
} else {
lazy.log.error(`Ignoring invalid list entry ${valueToString(item)}.`);
}
}
return result;
}
return value;
}
/*
* Stage 3. Build the value the policy implementations consume: uri-formatted
* strings become URL objects, and object properties the schema does not
* describe are stripped (or rejected when additional properties are
* disallowed). Runs only after validation succeeds.
*/
function hydrate(value, schema, allowAdditionalProperties) {
if (!schema || typeof schema != "object" || value == null) {
return value;
}
if (isUriSchema(schema) && typeof value == "string") {
if (value === "") {
return "";
}
try {
return new URL(value);
} catch {
// The validator accepted this string as a uri but the URL parser does
// not; leave it as-is rather than throwing.
return value;
}
}
if (typeof value == "object" && !Array.isArray(value)) {
if (schema.properties || schema.patternProperties) {
const result = {};
for (const key of Object.keys(value)) {
const subschema = subschemaForProperty(schema, key);
if (!subschema) {
if (allowAdditionalProperties) {
continue;
}
throw new PolicyParameterError(
`Object has unexpected property '${key}'`
);
}
result[key] = hydrate(value[key], subschema, allowAdditionalProperties);
}
return result;
}
return value;
}
if (Array.isArray(value) && schema.items) {
return value.map(item =>
hydrate(item, schema.items, allowAdditionalProperties)
);
}
return value;
}
export const PolicySchemaValidator = { validate };