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