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