authui-container/common/validator.ts (212 lines of code) (raw):

/*! * Copyright 2020 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as url from 'url'; // TODO: find cleaner way to have this work client and server side. const URL: any = url.URL || (window && window.URL); /** Defines a single validation node needed for validating JSON object. */ interface ValidationNode { nodes?: { [key: string]: ValidationNode; }; validator?: (value: any, key: string) => void; } /** Defines the JSON object validation tree. */ export interface ValidationTree { [key: string]: ValidationNode; } /** Basic IPv4 address regex matcher. */ const IP_ADDRESS_REGEXP = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; /** * Validates that a value is an array. * * @param value The value to validate. * @return Whether the value is an array or not. */ export function isArray(value: any): boolean { return Array.isArray(value); } /** * Validates that a value is a non-empty array. * * @param value The value to validate. * @return Whether the value is a non-empty array or not. */ export function isNonEmptyArray(value: any): boolean { return isArray(value) && value.length !== 0; } /** * Validates that a value is a boolean. * * @param value The value to validate. * @return Whether the value is a boolean or not. */ export function isBoolean(value: any): boolean { return typeof value === 'boolean'; } /** * Validates that a value is a number. * * @param value The value to validate. * @return Whether the value is a number or not. */ export function isNumber(value: any): boolean { return typeof value === 'number' && !isNaN(value); } /** * Validates that a value is a string. * * @param value The value to validate. * @return Whether the value is a string or not. */ export function isString(value: any): value is string { return typeof value === 'string'; } /** * Validates that a value is a non-empty string. * * @param value The value to validate. * @return Whether the value is a non-empty string or not. */ export function isNonEmptyString(value: any): value is string { return isString(value) && value !== ''; } /** * Validates that a value is a nullable object. * * @param value The value to validate. * @return Whether the value is an object or not. */ export function isObject(value: any): boolean { return typeof value === 'object' && !isArray(value); } /** * Validates that a value is a non-null object. * * @param value The value to validate. * @return Whether the value is a non-null object or not. */ export function isNonNullObject(value: any): boolean { return isObject(value) && value !== null; } /** * Validates that a string is a valid web URL. * * @param urlStr The string to validate. * @return Whether the string is valid web URL or not. */ export function isURL(urlStr: any): boolean { if (typeof urlStr !== 'string') { return false; } // Lookup illegal characters. const re = /[^a-z0-9\:\/\?\#\[\]\@\!\$\&\'\(\)\*\+\,\;\=\.\-\_\~\%]/i; if (re.test(urlStr)) { return false; } try { const uri = new URL(urlStr); const scheme = uri.protocol; const hostname = uri.hostname; const pathname = uri.pathname; if (scheme !== 'http:' && scheme !== 'https:') { return false; } // Validate hostname: Can contain letters, numbers, underscore and dashes separated by a dot. // Each zone must not start with a hyphen or underscore. if (!/^[a-zA-Z0-9]+[\w\-]*([\.]?[a-zA-Z0-9]+[\w\-]*)*$/.test(hostname)) { return false; } // Allow for pathnames: (/+chars+)*/* // Where chars can be a combination of: a-z A-Z 0-9 - _ . ~ ! $ & ' ( ) * + , ; = : @ % const pathnameRe = /^(\/+[\w\-\.\~\!\$\'\(\)\*\+\,\;\=\:\@\%]+)*\/*$/; // Validate pathname. if (pathname && !/^\/+$/.test(pathname) && !pathnameRe.test(pathname)) { return false; } // Allow any query string and hash as long as no invalid character is used. } catch (e) { return false; } return true; } /** * Validates that a string is a valid HTTPS URL. * * @param urlStr The string to validate. * @return Whether the string is valid HTTPS URL or not. */ export function isHttpsURL(urlStr: any): boolean { return isURL(urlStr) && new URL(urlStr).protocol === 'https:'; } /** * Validates that a string is localhost or a valid HTTPS URL. * This is needed to facilitate testing. As localhost is always served locally, there is no * risk of man in the middle attack. * * @param urlStr The string to validate. * @return Whether the string is localhost/valid HTTPS URL or not. */ export function isLocalhostOrHttpsURL(urlStr: any): boolean { if (isURL(urlStr)) { const uri = new URL(urlStr); return (uri.protocol === 'http:' && uri.hostname === 'localhost') || uri.protocol === 'https:'; } return false; } /** * Validates that a string is a valid email. * * @param email The string to validate. * @return Whether the string is valid email or not. */ export function isEmail(email: any): boolean { if (typeof email !== 'string') { return false; } // There must at least one character before the @ symbol and another after. const re = /^[^@]+@[^@]+$/; return re.test(email); } /** * Validates that a string is a valid provider ID. * * @param providerId The string to validate. * @return Whether the string is valid provider ID or not. */ export function isProviderId(providerId: any): boolean { if (typeof providerId !== 'string') { return false; } // This check is quite lax. It may be tightened in the future. const re = /^[a-zA-Z0-9\-\_\.]+$/; return isNonEmptyString(providerId) && re.test(providerId); } /** * Validates that a value is an empty object. * * @param value The value to validate. * @return Whether the value is an empty object. */ export function isEmptyObject(value: any): boolean { return isNonNullObject(value) && Object.keys(value).length === 0 && value.constructor === Object; } /** * Validates that the input in a valid color string of format #00ff00. * * @param value The string to validate. * @return Whether the string is a valid color string. */ export function isValidColorString(value: any): boolean { if (typeof value !== 'string') { return false; } // Actually raw strings and rgba(50, 75, 75, 1) formats are allowed but for // simplicity limit to this format. const re = /^#[0-9a-f]{6}$/i; return isNonEmptyString(value) && re.test(value); } /** * Validates that the input is a safe string. This minimizes the risk of XSS. * * @param value The string to validate. * @return Whether the string is safe or not. */ export function isSafeString(value: any): boolean { if (typeof value !== 'string') { return false; } // This check only allows limited set of characters and spaces. const re = /^[a-zA-Z0-9\-\_\.\s\,\+\?\!\&\;]+$/; return isNonEmptyString(value) && re.test(value); } /** * Utility used to validate a JSON object with nested content using a provided validation tree structure. * For the following interface: * interface MyStructure { * [key: string]: { * key1: string; * key2: string[]; * key3: { * key4: (boolean | {key5: number}); * }; * }; * } * * The following ValidationTree is provided: * const VALIDATION_TREE = { * '*': { * nodes: { * key1: { * validator: (input: any) => { // if input not string, throw }, * }, * key2[]: { * validator: (input: any) => { // if input not string, throw }, * }, * key3: { * nodes: { * key4: { * validator: (input: any) => { // if input not boolean, throw }, * nodes: { * key5: { * validator: (input: any) => { // if input not number, throw }, * }, * }, * }, * }, * }, * }, * }, * }; * Required fields can also be enforced: * const requiredFields = ['*.key1', '*.key2[]', '*.key3.key4.key5']; */ export class JsonObjectValidator { /** * Instantiates a JSON object validator using the provided validation tree. * @param validationTree The validation tree to use. * @param requiredFields list of required field paths. */ constructor( private readonly validationTree: ValidationTree, private readonly requiredFields: string[] = []) {} /** * Validates the provided object. * @param obj The object to validate. */ validate(obj: any) { this.validateJson(obj, []); this.checkRequiredFields(obj); } /** * Validates that all required fields are provided. * @param obj The object to validate. */ private checkRequiredFields(obj: any) { for (const requiredField of this.requiredFields) { this.validateRequired(obj, requiredField.split('.'), requiredField); } } /** * Validates that the list of component keys are available in the provided object. * @param obj The object to validate. * @param components The array of keys to continue traversing to ensure availability. * @param path The full path, useful for providing details in the error message. */ private validateRequired(obj: any, components: string[], path: string) { if (!components.length) { return; } const component = components[0]; if (component === '*') { const allKeys = Object.keys(obj); if (!allKeys.length) { throw new Error(`Missing required field "${path}"`); } for (const key of allKeys) { this.validateRequired(obj[key], components.slice(1), path); } } else if (component.substring(component.length - 2) === '[]') { const prefixKey = component.substring(0, component.length - 2); if (!isArray(obj[prefixKey])) { throw new Error(`Missing required field "${path}"`); } for (const entry of obj[prefixKey]) { this.validateRequired(entry, components.slice(1), path); } } else if (obj.hasOwnProperty(component)) { this.validateRequired(obj[component], components.slice(1), path); } else { throw new Error(`Missing required field "${path}"`); } } /** * Returns the validator function for the provided path. If not found, null is returned. * @param pathSoFar The path so far in the nested JSON object. * @return The validation function for the specified path, null if not found. */ private getValidator(pathSoFar: string[]): ((value: any, key: string) => void) | null { let currentNode: any = this.validationTree; let currentValidator: any = null; for (const currentKey of pathSoFar) { // For variable object keys, * is used in the validation tree. if (currentNode.hasOwnProperty('*')) { currentValidator = currentNode['*'].validator || ((value: any, key: string) => { if (!isEmptyObject(value)) { throw new Error(`Invalid value for "${key}"`); } }); currentNode = currentNode['*'].nodes || {}; } else if (currentNode.hasOwnProperty(currentKey)) { currentValidator = currentNode[currentKey].validator || ((value: any, key: string) => { if (!isEmptyObject(value)) { throw new Error(`Invalid value for "${key}"`); } }); currentNode = currentNode[currentKey].nodes || {}; } else { // Not found. return null; } } return currentValidator || null; } /** * Recursive internal validator. * @param obj The object to validate. * @param pathSoFar The path so far in the object. */ private validateJson(obj: any, pathSoFar: string[] = []) { if (isNonEmptyArray(obj)) { const key = pathSoFar.pop() || ''; pathSoFar.push(`${key}[]`); obj.forEach((item: any) => { this.validateJson(item, pathSoFar); }); pathSoFar.pop(); if (key) { pathSoFar.push(key); } } else if (isNonNullObject(obj) && !isEmptyObject(obj)) { for (const key in obj) { if (obj.hasOwnProperty(key)) { pathSoFar.push(key); this.validateJson(obj[key], pathSoFar); pathSoFar.pop(); } } } else if (isEmptyObject(obj)) { const validator = this.getValidator(pathSoFar); if (!validator) { throw new Error(`Invalid key or type "${pathSoFar.join('.')}"`); } } else if (isArray(obj) && obj.length === 0) { const key = pathSoFar.pop() || ''; pathSoFar.push(`${key}[]`); const validator = this.getValidator(pathSoFar); if (!validator) { throw new Error(`Invalid key or type "${pathSoFar.join('.')}"`); } } else { const validator = this.getValidator(pathSoFar); if (!validator) { throw new Error(`Invalid key or type "${pathSoFar.join('.')}"`); } validator(obj, pathSoFar.join('.')); } } }