• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1'use strict';
2
3// #region imports
4const {
5  ArrayIsArray,
6  ArrayPrototypeSort,
7  ObjectCreate,
8  ObjectEntries,
9  ObjectFreeze,
10  ObjectKeys,
11  ObjectSetPrototypeOf,
12  RegExpPrototypeExec,
13  RegExpPrototypeTest,
14  SafeMap,
15  SafeSet,
16  StringPrototypeEndsWith,
17  StringPrototypeReplace,
18  Symbol,
19  uncurryThis,
20} = primordials;
21const {
22  ERR_MANIFEST_ASSERT_INTEGRITY,
23  ERR_MANIFEST_INVALID_RESOURCE_FIELD,
24  ERR_MANIFEST_INVALID_SPECIFIER,
25  ERR_MANIFEST_UNKNOWN_ONERROR,
26} = require('internal/errors').codes;
27let debug = require('internal/util/debuglog').debuglog('policy', (fn) => {
28  debug = fn;
29});
30const SRI = require('internal/policy/sri');
31const crypto = require('crypto');
32const { Buffer } = require('buffer');
33const { URL } = require('internal/url');
34const { createHash, timingSafeEqual } = crypto;
35const HashUpdate = uncurryThis(crypto.Hash.prototype.update);
36const HashDigest = uncurryThis(crypto.Hash.prototype.digest);
37const BufferToString = uncurryThis(Buffer.prototype.toString);
38const kRelativeURLStringPattern = /^\.{0,2}\//;
39const { getOptionValue } = require('internal/options');
40const shouldAbortOnUncaughtException = getOptionValue(
41  '--abort-on-uncaught-exception'
42);
43const { abort, exit, _rawDebug } = process;
44// #endregion
45
46// #region constants
47// From https://url.spec.whatwg.org/#special-scheme
48const kSpecialSchemes = new SafeSet([
49  'file:',
50  'ftp:',
51  'http:',
52  'https:',
53  'ws:',
54  'wss:',
55]);
56
57/**
58 * @type {symbol}
59 */
60const kCascade = Symbol('cascade');
61/**
62 * @type {symbol}
63 */
64const kFallThrough = Symbol('fall through');
65
66function REACTION_THROW(error) {
67  throw error;
68}
69
70function REACTION_EXIT(error) {
71  REACTION_LOG(error);
72  if (shouldAbortOnUncaughtException) {
73    abort();
74  }
75  exit(1);
76}
77
78function REACTION_LOG(error) {
79  _rawDebug(error.stack);
80}
81
82// #endregion
83
84// #region DependencyMapperInstance
85class DependencyMapperInstance {
86  /**
87   * @type {string}
88   */
89  href;
90  /**
91   * @type {DependencyMap | undefined}
92   */
93  #dependencies;
94  /**
95   * @type {PatternDependencyMap | undefined}
96   */
97  #patternDependencies;
98  /**
99   * @type {DependencyMapperInstance | null | undefined}
100   */
101  #parentDependencyMapper;
102  /**
103   * @type {boolean}
104   */
105  #normalized = false;
106  /**
107   * @type {boolean}
108   */
109  cascade;
110  /**
111   * @type {boolean}
112   */
113  allowSameHREFScope;
114  /**
115   * @param {string} parentHREF
116   * @param {DependencyMap | undefined} dependencies
117   * @param {boolean} cascade
118   * @param {boolean} allowSameHREFScope
119   */
120  constructor(
121    parentHREF,
122    dependencies,
123    cascade = false,
124    allowSameHREFScope = false) {
125    this.href = parentHREF;
126    if (dependencies === kFallThrough ||
127        dependencies === undefined ||
128        dependencies === null) {
129      this.#dependencies = dependencies;
130      this.#patternDependencies = undefined;
131    } else {
132      const patterns = [];
133      for (const { 0: key } of ObjectEntries(dependencies)) {
134        if (StringPrototypeEndsWith(key, '*')) {
135          const target = RegExpPrototypeExec(/^([^*]*)\*([^*]*)$/);
136          if (!target) {
137            throw new ERR_MANIFEST_INVALID_SPECIFIER(
138              this.href,
139              target +
140                ', pattern needs to have a single' +
141                'trailing "*" in target');
142          }
143          const prefix = target[1];
144          const suffix = target[2];
145          patterns.push([
146            target.slice(0, -1),
147            [prefix, suffix],
148          ]);
149        }
150      }
151      ArrayPrototypeSort(patterns, (a, b) => {
152        return a[0] < b[0] ? -1 : 1;
153      });
154      this.#dependencies = dependencies;
155      this.#patternDependencies = patterns;
156    }
157    this.cascade = cascade;
158    this.allowSameHREFScope = allowSameHREFScope;
159    ObjectFreeze(this);
160  }
161  /**
162   *
163   * @param {string} normalizedSpecifier
164   * @param {Set<string>} conditions
165   * @param {Manifest} manifest
166   * @returns {URL | typeof kFallThrough | null}
167   */
168  _resolveAlreadyNormalized(normalizedSpecifier, conditions, manifest) {
169    let dependencies = this.#dependencies;
170    debug(this.href, 'resolving', normalizedSpecifier);
171    if (dependencies === kFallThrough) return true;
172    if (dependencies !== undefined && typeof dependencies === 'object') {
173      const normalized = this.#normalized;
174      if (normalized !== true) {
175        /**
176         * @type {Record<string, string>}
177         */
178        const normalizedDependencyMap = ObjectCreate(null);
179        for (let specifier in dependencies) {
180          const target = dependencies[specifier];
181          specifier = canonicalizeSpecifier(specifier, manifest.href);
182          normalizedDependencyMap[specifier] = target;
183        }
184        ObjectFreeze(normalizedDependencyMap);
185        dependencies = normalizedDependencyMap;
186        this.#dependencies = normalizedDependencyMap;
187        this.#normalized = true;
188      }
189      debug(dependencies);
190      if (normalizedSpecifier in dependencies === true) {
191        const to = searchDependencies(
192          this.href,
193          dependencies[normalizedSpecifier],
194          conditions
195        );
196        debug({ to });
197        if (to === true) {
198          return true;
199        }
200        let ret;
201        if (parsedURLs && parsedURLs.has(to)) {
202          ret = parsedURLs.get(to);
203        } else if (RegExpPrototypeTest(kRelativeURLStringPattern, to)) {
204          ret = resolve(to, manifest.href);
205        } else {
206          ret = resolve(to);
207        }
208        return ret;
209      }
210    }
211    const { cascade } = this;
212    if (cascade !== true) {
213      return null;
214    }
215    let parentDependencyMapper = this.#parentDependencyMapper;
216    if (parentDependencyMapper === undefined) {
217      parentDependencyMapper = manifest.getScopeDependencyMapper(
218        this.href,
219        this.allowSameHREFScope
220      );
221      this.#parentDependencyMapper = parentDependencyMapper;
222    }
223    if (parentDependencyMapper === null) {
224      return null;
225    }
226    return parentDependencyMapper._resolveAlreadyNormalized(
227      normalizedSpecifier,
228      conditions,
229      manifest
230    );
231  }
232}
233
234const kArbitraryDependencies = new DependencyMapperInstance(
235  'arbitrary dependencies',
236  kFallThrough,
237  false,
238  true
239);
240const kNoDependencies = new DependencyMapperInstance(
241  'no dependencies',
242  null,
243  false,
244  true
245);
246/**
247 * @param {string} href
248 * @param {JSONDependencyMap} dependencies
249 * @param {boolean} cascade
250 * @param {Map<string | null | undefined, DependencyMapperInstance>} store
251 */
252const insertDependencyMap = (
253  href,
254  dependencies,
255  cascade,
256  allowSameHREFScope,
257  store
258) => {
259  if (cascade !== undefined && typeof cascade !== 'boolean') {
260    throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'cascade');
261  }
262  if (dependencies === true) {
263    store.set(href, kArbitraryDependencies);
264    return;
265  }
266  if (dependencies === null || dependencies === undefined) {
267    store.set(
268      href,
269      cascade ?
270        new DependencyMapperInstance(href, null, true, allowSameHREFScope) :
271        kNoDependencies
272    );
273    return;
274  }
275  if (objectButNotArray(dependencies)) {
276    store.set(
277      href,
278      new DependencyMapperInstance(
279        href,
280        dependencies,
281        cascade,
282        allowSameHREFScope
283      )
284    );
285    return;
286  }
287  throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'dependencies');
288};
289/**
290 * Finds the longest key within `this.#scopeDependencies` that covers a
291 * specific HREF
292 * @param {string} href
293 * @param {ScopeStore} scopeStore
294 * @returns {null | string}
295 */
296function findScopeHREF(href, scopeStore, allowSame) {
297  let protocol;
298  if (href !== '') {
299    // default URL parser does some stuff to special urls... skip if this is
300    // just the protocol
301    if (RegExpPrototypeTest(/^[^:]*[:]$/, href)) {
302      protocol = href;
303    } else {
304      let currentURL = new URL(href);
305      const normalizedHREF = currentURL.href;
306      protocol = currentURL.protocol;
307      // Non-opaque blobs adopt origins
308      if (protocol === 'blob:' && currentURL.origin !== 'null') {
309        currentURL = new URL(currentURL.origin);
310        protocol = currentURL.protocol;
311      }
312      // Only a few schemes are hierarchical
313      if (kSpecialSchemes.has(currentURL.protocol)) {
314        // Make first '..' act like '.'
315        if (!StringPrototypeEndsWith(currentURL.pathname, '/')) {
316          currentURL.pathname += '/';
317        }
318        let lastHREF;
319        let currentHREF = currentURL.href;
320        do {
321          if (scopeStore.has(currentHREF)) {
322            if (allowSame || currentHREF !== normalizedHREF) {
323              return currentHREF;
324            }
325          }
326          lastHREF = currentHREF;
327          currentURL = new URL('..', currentURL);
328          currentHREF = currentURL.href;
329        } while (lastHREF !== currentHREF);
330      }
331    }
332  }
333  if (scopeStore.has(protocol)) {
334    if (allowSame || protocol !== href) return protocol;
335  }
336  if (scopeStore.has('')) {
337    if (allowSame || '' !== href) return '';
338  }
339  return null;
340}
341// #endregion
342
343/**
344 * @typedef {Record<string, string> | typeof kFallThrough} DependencyMap
345 * @typedef {Array<[string, [string, string]]>} PatternDependencyMap
346 * @typedef {Record<string, string> | null | true} JSONDependencyMap
347 */
348/**
349 * @typedef {Map<string, any>} ScopeStore
350 * @typedef {(specifier: string) => true | URL} DependencyMapper
351 * @typedef {boolean | string | SRI[] | typeof kCascade} Integrity
352 */
353
354class Manifest {
355  #defaultDependencies;
356  /**
357   * @type {string}
358   */
359  href;
360  /**
361   * @type {(err: Error) => void}
362   *
363   * Performs default action for what happens when a manifest encounters
364   * a violation such as abort()ing or exiting the process, throwing the error,
365   * or logging the error.
366   */
367  #reaction;
368  /**
369   * @type {Map<string, DependencyMapperInstance>}
370   *
371   * Used to find where a dependency is located.
372   *
373   * This stores functions to lazily calculate locations as needed.
374   * `true` is used to signify that the location is not specified
375   * by the manifest and default resolution should be allowed.
376   *
377   * The functions return `null` to signify that a dependency is
378   * not found
379   */
380  #resourceDependencies = new SafeMap();
381  /**
382   * @type {Map<string, Integrity>}
383   *
384   * Used to compare a resource to the content body at the resource.
385   * `true` is used to signify that all integrities are allowed, otherwise,
386   * SRI strings are parsed to compare with the body.
387   *
388   * This stores strings instead of eagerly parsing SRI strings
389   * and only converts them to SRI data structures when needed.
390   * This avoids needing to parse all SRI strings at startup even
391   * if some never end up being used.
392   */
393  #resourceIntegrities = new SafeMap();
394  /**
395   * @type {ScopeStore}
396   *
397   * Used to compare a resource to the content body at the resource.
398   * `true` is used to signify that all integrities are allowed, otherwise,
399   * SRI strings are parsed to compare with the body.
400   *
401   * Separate from #resourceDependencies due to conflicts with things like
402   * `blob:` being both a scope and a resource potentially as well as
403   * `file:` being parsed to `file:///` instead of remaining host neutral.
404   */
405  #scopeDependencies = new SafeMap();
406  /**
407   * @type {Map<string, boolean | null | typeof kCascade>}
408   *
409   * Used to allow arbitrary loading within a scope
410   */
411  #scopeIntegrities = new SafeMap();
412  /**
413   * `obj` should match the policy file format described in the docs
414   * it is expected to not have prototype pollution issues either by reassigning
415   * the prototype to `null` for values or by running prior to any user code.
416   *
417   * `manifestURL` is a URL to resolve relative locations against.
418   *
419   * @param {object} obj
420   * @param {string} manifestHREF
421   */
422  constructor(obj, manifestHREF) {
423    this.href = manifestHREF;
424    const scopes = this.#scopeDependencies;
425    const integrities = this.#resourceIntegrities;
426    const resourceDependencies = this.#resourceDependencies;
427    let reaction = REACTION_THROW;
428
429    if (objectButNotArray(obj) && 'onerror' in obj) {
430      const behavior = obj.onerror;
431      if (behavior === 'throw') {
432      } else if (behavior === 'exit') {
433        reaction = REACTION_EXIT;
434      } else if (behavior === 'log') {
435        reaction = REACTION_LOG;
436      } else {
437        throw new ERR_MANIFEST_UNKNOWN_ONERROR(behavior);
438      }
439    }
440
441    this.#reaction = reaction;
442    const jsonResourcesEntries = ObjectEntries(
443      obj.resources ?? ObjectCreate(null)
444    );
445    const jsonScopesEntries = ObjectEntries(obj.scopes ?? ObjectCreate(null));
446    const defaultDependencies = obj.dependencies ?? ObjectCreate(null);
447
448    this.#defaultDependencies = new DependencyMapperInstance(
449      'default',
450      defaultDependencies === true ? kFallThrough : defaultDependencies,
451      false
452    );
453
454    for (let i = 0; i < jsonResourcesEntries.length; i++) {
455      const { 0: originalHREF, 1: descriptor } = jsonResourcesEntries[i];
456      const { cascade, dependencies, integrity } = descriptor;
457      const href = resolve(originalHREF, manifestHREF).href;
458
459      if (typeof integrity !== 'undefined') {
460        debug('Manifest contains integrity for resource %s', originalHREF);
461        if (typeof integrity === 'string') {
462          integrities.set(href, integrity);
463        } else if (integrity === true) {
464          integrities.set(href, true);
465        } else {
466          throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'integrity');
467        }
468      } else {
469        integrities.set(href, cascade === true ? kCascade : false);
470      }
471      insertDependencyMap(
472        href,
473        dependencies,
474        cascade,
475        true,
476        resourceDependencies
477      );
478    }
479
480    const scopeIntegrities = this.#scopeIntegrities;
481    for (let i = 0; i < jsonScopesEntries.length; i++) {
482      const { 0: originalHREF, 1: descriptor } = jsonScopesEntries[i];
483      const { cascade, dependencies, integrity } = descriptor;
484      const href = emptyOrProtocolOrResolve(originalHREF, manifestHREF);
485      if (typeof integrity !== 'undefined') {
486        debug('Manifest contains integrity for scope %s', originalHREF);
487        if (integrity === true) {
488          scopeIntegrities.set(href, true);
489        } else {
490          throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'integrity');
491        }
492      } else {
493        scopeIntegrities.set(href, cascade === true ? kCascade : false);
494      }
495      insertDependencyMap(href, dependencies, cascade, false, scopes);
496    }
497
498    ObjectFreeze(this);
499  }
500
501  /**
502   * @param {string} requester
503   * @returns {{resolve: any, reaction: (err: any) => void}}
504   */
505  getDependencyMapper(requester) {
506    const requesterHREF = `${requester}`;
507    const dependencies = this.#resourceDependencies;
508    /**
509     * @type {DependencyMapperInstance}
510     */
511    const instance = (
512      dependencies.has(requesterHREF) ?
513        dependencies.get(requesterHREF) ?? null :
514        this.getScopeDependencyMapper(requesterHREF, true)
515    ) ?? this.#defaultDependencies;
516    return {
517      resolve: (specifier, conditions) => {
518        const normalizedSpecifier = canonicalizeSpecifier(
519          specifier,
520          requesterHREF
521        );
522        const result = instance._resolveAlreadyNormalized(
523          normalizedSpecifier,
524          conditions,
525          this
526        );
527        if (result === kFallThrough) return true;
528        return result;
529      },
530      reaction: this.#reaction,
531    };
532  }
533
534  assertIntegrity(url, content) {
535    const href = `${url}`;
536    debug('Checking integrity of %s', href);
537    const realIntegrities = new SafeMap();
538    const integrities = this.#resourceIntegrities;
539    function processEntry(href) {
540      let integrityEntries = integrities.get(href);
541      if (integrityEntries === true) return true;
542      if (typeof integrityEntries === 'string') {
543        const sri = ObjectFreeze(SRI.parse(integrityEntries));
544        integrities.set(href, sri);
545        integrityEntries = sri;
546      }
547      return integrityEntries;
548    }
549    if (integrities.has(href)) {
550      const integrityEntries = processEntry(href);
551      if (integrityEntries === true) return true;
552      if (ArrayIsArray(integrityEntries)) {
553        // Avoid clobbered Symbol.iterator
554        for (let i = 0; i < integrityEntries.length; i++) {
555          const { algorithm, value: expected } = integrityEntries[i];
556          const hash = createHash(algorithm);
557          // TODO(tniessen): the content should not be passed as a string in the
558          // first place, see https://github.com/nodejs/node/issues/39707
559          HashUpdate(hash, content, 'utf8');
560          const digest = HashDigest(hash, 'buffer');
561          if (digest.length === expected.length &&
562            timingSafeEqual(digest, expected)) {
563            return true;
564          }
565          realIntegrities.set(algorithm, BufferToString(digest, 'base64'));
566        }
567      }
568
569      if (integrityEntries !== kCascade) {
570        const error = new ERR_MANIFEST_ASSERT_INTEGRITY(url, realIntegrities);
571        this.#reaction(error);
572      }
573    }
574    let scope = findScopeHREF(href, this.#scopeIntegrities, true);
575    while (scope !== null) {
576      if (this.#scopeIntegrities.has(scope)) {
577        const entry = this.#scopeIntegrities.get(scope);
578        if (entry === true) {
579          return true;
580        } else if (entry === kCascade) {
581        } else {
582          break;
583        }
584      }
585      const nextScope = findScopeHREF(scope, this.#scopeDependencies, false);
586      if (!nextScope) {
587        break;
588      }
589      scope = nextScope;
590    }
591    const error = new ERR_MANIFEST_ASSERT_INTEGRITY(url, realIntegrities);
592    this.#reaction(error);
593  }
594  /**
595   * @param {string} href
596   * @param {boolean} allowSameHREFScope
597   * @returns {DependencyMapperInstance | null}
598   */
599  getScopeDependencyMapper(href, allowSameHREFScope) {
600    if (href === null) {
601      return this.#defaultDependencies;
602    }
603    /** @type {string | null} */
604    const scopeHREF = findScopeHREF(
605      href,
606      this.#scopeDependencies,
607      allowSameHREFScope
608    );
609    if (scopeHREF === null) return this.#defaultDependencies;
610    return this.#scopeDependencies.get(scopeHREF);
611  }
612}
613
614// Lock everything down to avoid problems even if reference is leaked somehow
615ObjectSetPrototypeOf(Manifest, null);
616ObjectSetPrototypeOf(Manifest.prototype, null);
617ObjectFreeze(Manifest);
618ObjectFreeze(Manifest.prototype);
619module.exports = ObjectFreeze({ Manifest });
620
621// #region URL utils
622
623/**
624 * Attempts to canonicalize relative URL strings against a base URL string
625 * Does not perform I/O
626 * If not able to canonicalize, returns the original specifier
627 *
628 * This effectively removes the possibility of the return value being a relative
629 * URL string
630 * @param {string} specifier
631 * @param {string} base
632 * @returns {string}
633 */
634function canonicalizeSpecifier(specifier, base) {
635  try {
636    if (RegExpPrototypeTest(kRelativeURLStringPattern, specifier)) {
637      return resolve(specifier, base).href;
638    }
639    return resolve(specifier).href;
640  } catch {}
641  return specifier;
642}
643
644/**
645 * Does a special allowance for scopes to be non-valid URLs
646 * that are only protocol strings or the empty string
647 * @param {string} resourceHREF
648 * @param {string} [base]
649 * @returns {string}
650 */
651const emptyOrProtocolOrResolve = (resourceHREF, base) => {
652  if (resourceHREF === '') return '';
653  if (StringPrototypeEndsWith(resourceHREF, ':')) {
654    // URL parse will trim these anyway, save the compute
655    resourceHREF = StringPrototypeReplace(
656      resourceHREF,
657      // eslint-disable-next-line
658      /^[\x00-\x1F\x20]|\x09\x0A\x0D|[\x00-\x1F\x20]$/g,
659      ''
660    );
661    if (RegExpPrototypeTest(/^[a-zA-Z][a-zA-Z+\-.]*:$/, resourceHREF)) {
662      return resourceHREF;
663    }
664  }
665  return resolve(resourceHREF, base).href;
666};
667
668/**
669 * @type {Map<string, URL>}
670 */
671let parsedURLs;
672/**
673 * Resolves a valid url string and uses the parsed cache to avoid double parsing
674 * costs.
675 * @param {string} originalHREF
676 * @param {string} [base]
677 * @returns {Readonly<URL>}
678 */
679const resolve = (originalHREF, base) => {
680  parsedURLs = parsedURLs ?? new SafeMap();
681  if (parsedURLs.has(originalHREF)) {
682    return parsedURLs.get(originalHREF);
683  } else if (RegExpPrototypeTest(kRelativeURLStringPattern, originalHREF)) {
684    const resourceURL = new URL(originalHREF, base);
685    parsedURLs.set(resourceURL.href, resourceURL);
686    return resourceURL;
687  }
688  const resourceURL = new URL(originalHREF);
689  parsedURLs.set(originalHREF, resourceURL);
690  return resourceURL;
691};
692
693// #endregion
694
695/**
696 * @param {any} o
697 * @returns {o is object}
698 */
699function objectButNotArray(o) {
700  return o && typeof o === 'object' && !ArrayIsArray(o);
701}
702
703function searchDependencies(href, target, conditions) {
704  if (objectButNotArray(target)) {
705    const keys = ObjectKeys(target);
706    for (let i = 0; i < keys.length; i++) {
707      const key = keys[i];
708      if (conditions.has(key)) {
709        const ret = searchDependencies(href, target[key], conditions);
710        if (ret != null) {
711          return ret;
712        }
713      }
714    }
715  } else if (typeof target === 'string') {
716    return target;
717  } else if (target === true) {
718    return target;
719  } else {
720    throw new ERR_MANIFEST_INVALID_RESOURCE_FIELD(href, 'dependencies');
721  }
722  return null;
723}
724