node/lib/internal/policy/manifest.js
Joyee Cheung be525d7d04
src: consolidate exit codes in the code base
Add an ExitCode enum class and use it throughout the code base
instead of hard-coding the exit codes everywhere. At the moment,
the exit codes used in many places do not actually conform to
what the documentation describes. With the new enums (which
are also available to the JS land as constants in an internal
binding) we could migrate to a more consistent usage of the
codes, and eventually expose the constants to the user land
when they are stable enough.

PR-URL: https://github.com/nodejs/node/pull/44746
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Jacob Smith <jacob@frende.me>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Darshan Sen <raisinten@gmail.com>
2022-10-06 12:41:16 +00:00

763 lines
22 KiB
JavaScript

'use strict';
// #region imports
const {
ArrayIsArray,
ArrayPrototypeSort,
ObjectCreate,
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 = ObjectCreate(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 ?? ObjectCreate(null)
);
const jsonScopesEntries = ObjectEntries(obj.scopes ?? ObjectCreate(null));
const defaultDependencies = obj.dependencies ?? ObjectCreate(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;
}