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