'use strict'; // #region imports const { ArrayIsArray, ArrayPrototypeSort, ObjectEntries, ObjectFreeze, ObjectKeys, ObjectSetPrototypeOf, RegExpPrototypeExec, SafeMap, SafeSet, RegExpPrototypeSymbolReplace, StringPrototypeEndsWith, StringPrototypeStartsWith, Symbol, uncurryThis, } = primordials; const { ERR_MANIFEST_ASSERT_INTEGRITY, ERR_MANIFEST_INVALID_RESOURCE_FIELD, ERR_MANIFEST_INVALID_SPECIFIER, ERR_MANIFEST_UNKNOWN_ONERROR, } = require('internal/errors').codes; let debug = require('internal/util/debuglog').debuglog('policy', (fn) => { debug = fn; }); const SRI = require('internal/policy/sri'); const crypto = require('crypto'); const { Buffer } = require('buffer'); const { URL } = require('internal/url'); const { createHash, timingSafeEqual } = crypto; const HashUpdate = uncurryThis(crypto.Hash.prototype.update); const HashDigest = uncurryThis(crypto.Hash.prototype.digest); const BufferToString = uncurryThis(Buffer.prototype.toString); const kRelativeURLStringPattern = /^\.{0,2}\//; const { getOptionValue } = require('internal/options'); const shouldAbortOnUncaughtException = getOptionValue( '--abort-on-uncaught-exception', ); const { exitCodes: { kGenericUserError } } = internalBinding('errors'); const { abort, exit, _rawDebug } = process; // #endregion // #region constants // From https://url.spec.whatwg.org/#special-scheme const kSpecialSchemes = new SafeSet([ 'file:', 'ftp:', 'http:', 'https:', 'ws:', 'wss:', ]); /** * @type {symbol} */ const kCascade = Symbol('cascade'); /** * @type {symbol} */ const kFallThrough = Symbol('fall through'); function REACTION_THROW(error) { throw error; } function REACTION_EXIT(error) { REACTION_LOG(error); if (shouldAbortOnUncaughtException) { abort(); } exit(kGenericUserError); } function REACTION_LOG(error) { _rawDebug(error.stack); } // #endregion // #region DependencyMapperInstance class DependencyMapperInstance { /** * @type {string} */ href; /** * @type {DependencyMap | undefined} */ #dependencies; /** * @type {PatternDependencyMap | undefined} */ #patternDependencies; /** * @type {DependencyMapperInstance | null | undefined} */ #parentDependencyMapper; /** * @type {boolean} */ #normalized = false; /** * @type {boolean} */ cascade; /** * @type {boolean} */ allowSameHREFScope; /** * @param {string} parentHREF * @param {DependencyMap | undefined} dependencies * @param {boolean} cascade * @param {boolean} allowSameHREFScope */ constructor( parentHREF, dependencies, cascade = false, allowSameHREFScope = false) { this.href = parentHREF; if (dependencies === kFallThrough || dependencies === undefined || dependencies === null) { this.#dependencies = dependencies; this.#patternDependencies = undefined; } else { const patterns = []; const keys = ObjectKeys(dependencies); for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (StringPrototypeEndsWith(key, '*')) { const target = RegExpPrototypeExec(/^([^*]*)\*([^*]*)$/); if (!target) { throw new ERR_MANIFEST_INVALID_SPECIFIER( this.href, `${target}, pattern needs to have a single trailing "*" in target`, ); } const prefix = target[1]; const suffix = target[2]; patterns.push([ target.slice(0, -1), [prefix, suffix], ]); } } ArrayPrototypeSort(patterns, (a, b) => { return a[0] < b[0] ? -1 : 1; }); this.#dependencies = dependencies; this.#patternDependencies = patterns; } this.cascade = cascade; this.allowSameHREFScope = allowSameHREFScope; ObjectFreeze(this); } /** * * @param {string} normalizedSpecifier * @param {Set} conditions * @param {Manifest} manifest * @returns {URL | typeof kFallThrough | null} */ _resolveAlreadyNormalized(normalizedSpecifier, conditions, manifest) { let dependencies = this.#dependencies; debug(this.href, 'resolving', normalizedSpecifier); if (dependencies === kFallThrough) return true; if (dependencies !== undefined && typeof dependencies === 'object') { const normalized = this.#normalized; if (normalized !== true) { /** * @type {Record} */ const normalizedDependencyMap = { __proto__: null }; for (let specifier in dependencies) { const target = dependencies[specifier]; specifier = canonicalizeSpecifier(specifier, manifest.href); normalizedDependencyMap[specifier] = target; } ObjectFreeze(normalizedDependencyMap); dependencies = normalizedDependencyMap; this.#dependencies = normalizedDependencyMap; this.#normalized = true; } debug(dependencies); if (normalizedSpecifier in dependencies === true) { const to = searchDependencies( this.href, dependencies[normalizedSpecifier], conditions, ); debug({ to }); if (to === true) { return true; } let ret; if (parsedURLs && parsedURLs.has(to)) { ret = parsedURLs.get(to); } else if (RegExpPrototypeExec(kRelativeURLStringPattern, to) !== null) { ret = resolve(to, manifest.href); } else { ret = resolve(to); } return ret; } } const { cascade } = this; if (cascade !== true) { return null; } let parentDependencyMapper = this.#parentDependencyMapper; if (parentDependencyMapper === undefined) { parentDependencyMapper = manifest.getScopeDependencyMapper( this.href, this.allowSameHREFScope, ); this.#parentDependencyMapper = parentDependencyMapper; } if (parentDependencyMapper === null) { return null; } return parentDependencyMapper._resolveAlreadyNormalized( normalizedSpecifier, conditions, manifest, ); } } const kArbitraryDependencies = new DependencyMapperInstance( 'arbitrary dependencies', kFallThrough, false, true, ); const kNoDependencies = new DependencyMapperInstance( 'no dependencies', null, false, true, ); /** * @param {string} href * @param {JSONDependencyMap} dependencies * @param {boolean} cascade * @param {boolean} allowSameHREFScope * @param {Map} store */ const insertDependencyMap = ( href, dependencies, cascade, allowSameHREFScope, store, ) => { if (cascade !== undefined && typeof cascade !== 'boolean') { throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'cascade'); } if (dependencies === true) { store.set(href, kArbitraryDependencies); return; } if (dependencies === null || dependencies === undefined) { store.set( href, cascade ? new DependencyMapperInstance(href, null, true, allowSameHREFScope) : kNoDependencies, ); return; } if (objectButNotArray(dependencies)) { store.set( href, new DependencyMapperInstance( href, dependencies, cascade, allowSameHREFScope, ), ); return; } throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'dependencies'); }; /** * Finds the longest key within `this.#scopeDependencies` that covers a * specific HREF * @param {string} href * @param {ScopeStore} scopeStore * @returns {null | string} */ function findScopeHREF(href, scopeStore, allowSame) { let protocol; if (href !== '') { // default URL parser does some stuff to special urls... skip if this is // just the protocol if (RegExpPrototypeExec(/^[^:]*[:]$/, href) !== null) { protocol = href; } else { let currentURL = new URL(href); const normalizedHREF = currentURL.href; protocol = currentURL.protocol; // Non-opaque blobs adopt origins if (protocol === 'blob:' && currentURL.origin !== 'null') { currentURL = new URL(currentURL.origin); protocol = currentURL.protocol; } // Only a few schemes are hierarchical if (kSpecialSchemes.has(currentURL.protocol)) { // Make first '..' act like '.' if (!StringPrototypeEndsWith(currentURL.pathname, '/')) { currentURL.pathname += '/'; } let lastHREF; let currentHREF = currentURL.href; do { if (scopeStore.has(currentHREF)) { if (allowSame || currentHREF !== normalizedHREF) { return currentHREF; } } lastHREF = currentHREF; currentURL = new URL('..', currentURL); currentHREF = currentURL.href; } while (lastHREF !== currentHREF); } } } if (scopeStore.has(protocol)) { if (allowSame || protocol !== href) return protocol; } if (scopeStore.has('')) { if (allowSame || '' !== href) return ''; } return null; } // #endregion /** * @typedef {Record | typeof kFallThrough} DependencyMap * @typedef {Array<[string, [string, string]]>} PatternDependencyMap * @typedef {Record | null | true} JSONDependencyMap */ /** * @typedef {Map} ScopeStore * @typedef {(specifier: string) => true | URL} DependencyMapper * @typedef {boolean | string | SRI[] | typeof kCascade} Integrity */ class Manifest { #defaultDependencies; /** * @type {string} */ href; /** * @type {(err: Error) => void} * * Performs default action for what happens when a manifest encounters * a violation such as abort()ing or exiting the process, throwing the error, * or logging the error. */ #reaction; /** * @type {Map} * * Used to find where a dependency is located. * * This stores functions to lazily calculate locations as needed. * `true` is used to signify that the location is not specified * by the manifest and default resolution should be allowed. * * The functions return `null` to signify that a dependency is * not found */ #resourceDependencies = new SafeMap(); /** * @type {Map} * * Used to compare a resource to the content body at the resource. * `true` is used to signify that all integrities are allowed, otherwise, * SRI strings are parsed to compare with the body. * * This stores strings instead of eagerly parsing SRI strings * and only converts them to SRI data structures when needed. * This avoids needing to parse all SRI strings at startup even * if some never end up being used. */ #resourceIntegrities = new SafeMap(); /** * @type {ScopeStore} * * Used to compare a resource to the content body at the resource. * `true` is used to signify that all integrities are allowed, otherwise, * SRI strings are parsed to compare with the body. * * Separate from #resourceDependencies due to conflicts with things like * `blob:` being both a scope and a resource potentially as well as * `file:` being parsed to `file:///` instead of remaining host neutral. */ #scopeDependencies = new SafeMap(); /** * @type {Map} * * Used to allow arbitrary loading within a scope */ #scopeIntegrities = new SafeMap(); /** * `obj` should match the policy file format described in the docs * it is expected to not have prototype pollution issues either by reassigning * the prototype to `null` for values or by running prior to any user code. * * `manifestURL` is a URL to resolve relative locations against. * * @param {object} obj * @param {string} manifestHREF */ constructor(obj, manifestHREF) { this.href = manifestHREF; const scopes = this.#scopeDependencies; const integrities = this.#resourceIntegrities; const resourceDependencies = this.#resourceDependencies; let reaction = REACTION_THROW; if (objectButNotArray(obj) && 'onerror' in obj) { const behavior = obj.onerror; if (behavior === 'exit') { reaction = REACTION_EXIT; } else if (behavior === 'log') { reaction = REACTION_LOG; } else if (behavior !== 'throw') { throw new ERR_MANIFEST_UNKNOWN_ONERROR(behavior); } } this.#reaction = reaction; const jsonResourcesEntries = ObjectEntries( obj.resources ?? { __proto__: null }, ); const jsonScopesEntries = ObjectEntries(obj.scopes ?? { __proto__: null }); const defaultDependencies = obj.dependencies ?? { __proto__: null }; this.#defaultDependencies = new DependencyMapperInstance( 'default', defaultDependencies === true ? kFallThrough : defaultDependencies, false, ); for (let i = 0; i < jsonResourcesEntries.length; i++) { const { 0: originalHREF, 1: descriptor } = jsonResourcesEntries[i]; const { cascade, dependencies, integrity } = descriptor; const href = resolve(originalHREF, manifestHREF).href; if (typeof integrity !== 'undefined') { debug('Manifest contains integrity for resource %s', originalHREF); if (typeof integrity === 'string') { integrities.set(href, integrity); } else if (integrity === true) { integrities.set(href, true); } else { throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'integrity'); } } else { integrities.set(href, cascade === true ? kCascade : false); } insertDependencyMap( href, dependencies, cascade, true, resourceDependencies, ); } const scopeIntegrities = this.#scopeIntegrities; for (let i = 0; i < jsonScopesEntries.length; i++) { const { 0: originalHREF, 1: descriptor } = jsonScopesEntries[i]; const { cascade, dependencies, integrity } = descriptor; const href = emptyOrProtocolOrResolve(originalHREF, manifestHREF); if (typeof integrity !== 'undefined') { debug('Manifest contains integrity for scope %s', originalHREF); if (integrity === true) { scopeIntegrities.set(href, true); } else { throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'integrity'); } } else { scopeIntegrities.set(href, cascade === true ? kCascade : false); } insertDependencyMap(href, dependencies, cascade, false, scopes); } ObjectFreeze(this); } /** * @param {string} requester * @returns {{resolve: any, reaction: (err: any) => void}} */ getDependencyMapper(requester) { const requesterHREF = `${requester}`; const dependencies = this.#resourceDependencies; /** * @type {DependencyMapperInstance} */ const instance = ( dependencies.has(requesterHREF) ? dependencies.get(requesterHREF) ?? null : this.getScopeDependencyMapper(requesterHREF, true) ) ?? this.#defaultDependencies; return { resolve: (specifier, conditions) => { const normalizedSpecifier = canonicalizeSpecifier( specifier, requesterHREF, ); const result = instance._resolveAlreadyNormalized( normalizedSpecifier, conditions, this, ); if (result === kFallThrough) return true; return result; }, reaction: this.#reaction, }; } mightAllow(url, onreact) { const href = `${url}`; debug('Checking for entry of %s', href); if (StringPrototypeStartsWith(href, 'node:')) { return true; } if (this.#resourceIntegrities.has(href)) { return true; } let scope = findScopeHREF(href, this.#scopeIntegrities, true); while (scope !== null) { if (this.#scopeIntegrities.has(scope)) { const entry = this.#scopeIntegrities.get(scope); if (entry === true) { return true; } else if (entry !== kCascade) { break; } } const nextScope = findScopeHREF( new URL('..', scope), this.#scopeIntegrities, false, ); if (!nextScope || nextScope === scope) { break; } scope = nextScope; } if (onreact) { this.#reaction(onreact()); } return false; } assertIntegrity(url, content) { const href = `${url}`; debug('Checking integrity of %s', href); const realIntegrities = new SafeMap(); const integrities = this.#resourceIntegrities; function processEntry(href) { let integrityEntries = integrities.get(href); if (integrityEntries === true) return true; if (typeof integrityEntries === 'string') { const sri = ObjectFreeze(SRI.parse(integrityEntries)); integrities.set(href, sri); integrityEntries = sri; } return integrityEntries; } if (integrities.has(href)) { const integrityEntries = processEntry(href); if (integrityEntries === true) return true; if (ArrayIsArray(integrityEntries)) { // Avoid clobbered Symbol.iterator for (let i = 0; i < integrityEntries.length; i++) { const { algorithm, value: expected } = integrityEntries[i]; const hash = createHash(algorithm); // TODO(tniessen): the content should not be passed as a string in the // first place, see https://github.com/nodejs/node/issues/39707 HashUpdate(hash, content, 'utf8'); const digest = HashDigest(hash, 'buffer'); if (digest.length === expected.length && timingSafeEqual(digest, expected)) { return true; } realIntegrities.set(algorithm, BufferToString(digest, 'base64')); } } if (integrityEntries !== kCascade) { const error = new ERR_MANIFEST_ASSERT_INTEGRITY(url, realIntegrities); this.#reaction(error); } } let scope = findScopeHREF(href, this.#scopeIntegrities, true); while (scope !== null) { if (this.#scopeIntegrities.has(scope)) { const entry = this.#scopeIntegrities.get(scope); if (entry === true) { return true; } else if (entry !== kCascade) { break; } } const nextScope = findScopeHREF(scope, this.#scopeDependencies, false); if (!nextScope) { break; } scope = nextScope; } const error = new ERR_MANIFEST_ASSERT_INTEGRITY(url, realIntegrities); this.#reaction(error); } /** * @param {string} href * @param {boolean} allowSameHREFScope * @returns {DependencyMapperInstance | null} */ getScopeDependencyMapper(href, allowSameHREFScope) { if (href === null) { return this.#defaultDependencies; } /** @type {string | null} */ const scopeHREF = findScopeHREF( href, this.#scopeDependencies, allowSameHREFScope, ); if (scopeHREF === null) return this.#defaultDependencies; return this.#scopeDependencies.get(scopeHREF); } } // Lock everything down to avoid problems even if reference is leaked somehow ObjectSetPrototypeOf(Manifest, null); ObjectSetPrototypeOf(Manifest.prototype, null); ObjectFreeze(Manifest); ObjectFreeze(Manifest.prototype); module.exports = ObjectFreeze({ Manifest }); // #region URL utils /** * Attempts to canonicalize relative URL strings against a base URL string * Does not perform I/O * If not able to canonicalize, returns the original specifier * * This effectively removes the possibility of the return value being a relative * URL string * @param {string} specifier * @param {string} base * @returns {string} */ function canonicalizeSpecifier(specifier, base) { try { if (RegExpPrototypeExec(kRelativeURLStringPattern, specifier) !== null) { return resolve(specifier, base).href; } return resolve(specifier).href; } catch { // Continue regardless of error. } return specifier; } /** * Does a special allowance for scopes to be non-valid URLs * that are only protocol strings or the empty string * @param {string} resourceHREF * @param {string} [base] * @returns {string} */ const emptyOrProtocolOrResolve = (resourceHREF, base) => { if (resourceHREF === '') return ''; if (StringPrototypeEndsWith(resourceHREF, ':')) { // URL parse will trim these anyway, save the compute resourceHREF = RegExpPrototypeSymbolReplace( // eslint-disable-next-line /^[\x00-\x1F\x20]|\x09\x0A\x0D|[\x00-\x1F\x20]$/g, resourceHREF, '', ); if (RegExpPrototypeExec(/^[a-zA-Z][a-zA-Z+\-.]*:$/, resourceHREF) !== null) { return resourceHREF; } } return resolve(resourceHREF, base).href; }; /** * @type {Map} */ let parsedURLs; /** * Resolves a valid url string and uses the parsed cache to avoid double parsing * costs. * @param {string} originalHREF * @param {string} [base] * @returns {Readonly} */ const resolve = (originalHREF, base) => { parsedURLs = parsedURLs ?? new SafeMap(); if (parsedURLs.has(originalHREF)) { return parsedURLs.get(originalHREF); } else if (RegExpPrototypeExec(kRelativeURLStringPattern, originalHREF) !== null) { const resourceURL = new URL(originalHREF, base); parsedURLs.set(resourceURL.href, resourceURL); return resourceURL; } const resourceURL = new URL(originalHREF); parsedURLs.set(originalHREF, resourceURL); return resourceURL; }; // #endregion /** * @param {any} o * @returns {o is object} */ function objectButNotArray(o) { return o && typeof o === 'object' && !ArrayIsArray(o); } function searchDependencies(href, target, conditions) { if (objectButNotArray(target)) { const keys = ObjectKeys(target); for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (conditions.has(key)) { const ret = searchDependencies(href, target[key], conditions); if (ret != null) { return ret; } } } } else if (typeof target === 'string') { return target; } else if (target === true) { return target; } else { throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'dependencies'); } return null; }