node/lib/internal/policy/manifest.js
Moshe Atlow 9658d84ddd tools: fix jsdoc lint
PR-URL: https://github.com/nodejs/node/pull/47789
Reviewed-By: Rich Trott <rtrott@gmail.com>
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Tobias Nießen <tniessen@tnie.de>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Mestery <mestery@protonmail.com>
2023-05-02 00:48:20 +00:00

761 lines
22 KiB
JavaScript

'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<string>} 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<string, string>}
*/
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<string | null | undefined, DependencyMapperInstance>} 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<string, string> | typeof kFallThrough} DependencyMap
* @typedef {Array<[string, [string, string]]>} PatternDependencyMap
* @typedef {Record<string, string> | null | true} JSONDependencyMap
*/
/**
* @typedef {Map<string, any>} 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<string, DependencyMapperInstance>}
*
* 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<string, Integrity>}
*
* 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<string, boolean | null | typeof kCascade>}
*
* 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<string, URL>}
*/
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<URL>}
*/
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;
}