MozCachedOHTTPProtocolHandler.sys.mjs (32430B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs", 11 E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", 12 NetUtil: "resource://gre/modules/NetUtil.sys.mjs", 13 }); 14 15 XPCOMUtils.defineLazyServiceGetter( 16 lazy, 17 "obliviousHttpService", 18 "@mozilla.org/network/oblivious-http-service;1", 19 Ci.nsIObliviousHttpService 20 ); 21 22 /** 23 * For now, the configuration and relay we're using is the "common" one, but 24 * the prefs are stored in a newtab-specific way. In the future, it's possible 25 * that we'll have different OHTTP configurations and relays for different 26 * types of resource requests, so we map the moz-cached-ohttp host to relay and 27 * config prefs here. 28 */ 29 const HOST_MAP = new Map([ 30 [ 31 "newtab-image", 32 { 33 gatewayConfigURLPrefName: 34 "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL", 35 relayURLPrefName: 36 "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL", 37 }, 38 ], 39 ]); 40 41 /** 42 * Protocol handler for the moz-cached-ohttp:// scheme. This handler enables 43 * loading resources over Oblivious HTTP (OHTTP) from privileged about: content 44 * processes, specifically for use for images in about:newtab. The handler 45 * implements a cache-first strategy to minimize OHTTP requests while providing 46 * fallback to OHTTP when resources are not available in the HTTP cache. 47 */ 48 export class MozCachedOHTTPProtocolHandler { 49 /** 50 * The protocol scheme handled by this handler. 51 */ 52 scheme = "moz-cached-ohttp"; 53 54 /** 55 * Injectable OHTTP service for testing. If null, uses the default service. 56 */ 57 #injectedOHTTPService = null; 58 59 /** 60 * Determines whether a given port is allowed for this protocol. 61 * 62 * @param {number} _port 63 * The port number to check. 64 * @param {string} _scheme 65 * The protocol scheme. 66 * @returns {boolean} 67 * Always false as this protocol doesn't use ports. 68 */ 69 allowPort(_port, _scheme) { 70 return false; 71 } 72 73 /** 74 * Creates a new channel for handling moz-cached-ohttp:// URLs. 75 * 76 * @param {nsIURI} uri 77 * The URI to create a channel for. 78 * @param {nsILoadInfo} loadInfo 79 * Load information containing security context. 80 * @returns {MozCachedOHTTPChannel} 81 * A new channel instance. 82 * @throws {Components.Exception} 83 * If the request is not from a valid context 84 */ 85 newChannel(uri, loadInfo) { 86 // Check if we're in a privileged about content process 87 if ( 88 Services.appinfo.remoteType == lazy.E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE 89 ) { 90 return new MozCachedOHTTPChannel( 91 uri, 92 loadInfo, 93 this, 94 this.#getOHTTPService(), 95 !!this.#injectedOHTTPService /* inTestingMode */ 96 ); 97 } 98 99 // In main process, allow system principals and check loading principal's remote type 100 if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) { 101 const loadingPrincipal = loadInfo?.loadingPrincipal; 102 if (loadingPrincipal) { 103 // Allow system principals in parent process (for tests and internal usage) 104 if (loadingPrincipal.isSystemPrincipal) { 105 return new MozCachedOHTTPChannel( 106 uri, 107 loadInfo, 108 this, 109 this.#getOHTTPService(), 110 !!this.#injectedOHTTPService /* inTestingMode */ 111 ); 112 } 113 114 try { 115 const remoteType = 116 lazy.E10SUtils.getRemoteTypeForPrincipal(loadingPrincipal); 117 if (remoteType === lazy.E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE) { 118 return new MozCachedOHTTPChannel( 119 uri, 120 loadInfo, 121 this, 122 this.#getOHTTPService(), 123 !!this.#injectedOHTTPService /* inTestingMode */ 124 ); 125 } 126 } catch (e) { 127 // E10SUtils might throw for invalid principals, fall through to rejection 128 } 129 } 130 } 131 132 throw Components.Exception( 133 "moz-cached-ohttp protocol only accessible from privileged about content", 134 Cr.NS_ERROR_INVALID_ARG 135 ); 136 } 137 138 /** 139 * Gets both the gateway configuration and relay URI for making OHTTP 140 * requests. 141 * 142 * @param {string} host 143 * A key for an entry in HOST_MAP that determines which OHTTP configuration 144 * and relay will be used for the request (e.g., "newtab-image"). 145 * @returns {Promise<{ohttpGatewayConfig: Uint8Array, relayURI: nsIURI}>} 146 * Promise that resolves to an object containing both OHTTP components: 147 * @returns {Uint8Array} returns.ohttpGatewayConfig 148 * The binary OHTTP gateway configuration 149 * @returns {nsIURI} returns.relayURI 150 * The nsIURI for the OHTTP relay endpoint 151 * @throws {Error} 152 * If the host is unrecognized, or if either the gateway config URL or relay 153 * URL preferences are not configured, or if fetching the gateway 154 * configuration fails. 155 */ 156 async getOHTTPGatewayConfigAndRelayURI(host) { 157 let hostMapping = HOST_MAP.get(host); 158 if (!hostMapping) { 159 throw new Error(`Unrecognized host for OHTTP config: ${host}`); 160 } 161 if ( 162 hostMapping.gatewayConfigURL === undefined || 163 hostMapping.relayURL === undefined 164 ) { 165 XPCOMUtils.defineLazyPreferenceGetter( 166 hostMapping, 167 "gatewayConfigURL", 168 hostMapping.gatewayConfigURLPrefName, 169 "" 170 ); 171 XPCOMUtils.defineLazyPreferenceGetter( 172 hostMapping, 173 "relayURL", 174 hostMapping.relayURLPrefName, 175 "" 176 ); 177 } 178 if (!hostMapping.gatewayConfigURL) { 179 throw new Error( 180 `OHTTP Gateway config URL not configured for host: ${host}` 181 ); 182 } 183 if (!hostMapping.relayURL) { 184 throw new Error(`OHTTP relay URL not configured for host: ${host}`); 185 } 186 187 const ohttpGatewayConfig = await lazy.ObliviousHTTP.getOHTTPConfig( 188 hostMapping.gatewayConfigURL 189 ); 190 191 return { 192 ohttpGatewayConfig, 193 relayURI: Services.io.newURI(hostMapping.relayURL), 194 }; 195 } 196 197 /** 198 * Injects an OHTTP service for testing purposes. 199 * 200 * @param {nsIObliviousHttpService} service 201 * The service to inject, or null to use default. 202 */ 203 injectOHTTPService(service) { 204 this.#injectedOHTTPService = service; 205 } 206 207 /** 208 * Gets the OHTTP service to use (injected or default). 209 * 210 * @returns {nsIObliviousHttpService} 211 * The OHTTP service to use. 212 */ 213 #getOHTTPService() { 214 return this.#injectedOHTTPService || lazy.obliviousHttpService; 215 } 216 217 QueryInterface = ChromeUtils.generateQI(["nsIProtocolHandler"]); 218 } 219 220 /** 221 * Channel implementation for moz-cached-ohttp:// URLs. This channel first attempts 222 * to load resources from the HTTP cache to avoid unnecessary OHTTP requests, and 223 * falls back to loading via OHTTP if the resource is not cached. 224 */ 225 export class MozCachedOHTTPChannel { 226 #uri; 227 #loadInfo; 228 #protocolHandler; 229 #ohttpService; 230 #listener = null; 231 #loadFlags = 0; 232 #status = Cr.NS_OK; 233 #cancelled = false; 234 #originalURI; 235 #contentType = ""; 236 #contentCharset = ""; 237 #contentLength = -1; 238 #owner = null; 239 #securityInfo = null; 240 #notificationCallbacks = null; 241 #loadGroup = null; 242 #pendingChannel = null; 243 #startedRequest = false; 244 #inTestingMode = false; 245 246 /** 247 * Constructs a new MozCachedOHTTPChannel. 248 * 249 * @param {nsIURI} uri 250 * The moz-cached-ohttp:// URI to handle. 251 * @param {nsILoadInfo} loadInfo 252 * Load information for the request with security context. 253 * @param {MozCachedOHTTPProtocolHandler} protocolHandler 254 * The protocol handler instance that created this channel. 255 * @param {nsIObliviousHttpService} ohttpService 256 * The OHTTP service to use. 257 * @param {boolean} inTestingMode 258 * True if the channel is in a mode that makes it easier to stub and 259 * mock things out while under test. 260 */ 261 constructor( 262 uri, 263 loadInfo, 264 protocolHandler, 265 ohttpService, 266 inTestingMode = false 267 ) { 268 this.#uri = uri; 269 this.#loadInfo = loadInfo; 270 this.#protocolHandler = protocolHandler; 271 this.#ohttpService = ohttpService; 272 this.#originalURI = uri; 273 this.#inTestingMode = inTestingMode; 274 } 275 276 /** 277 * Gets the URI for this channel. 278 * 279 * @returns {nsIURI} 280 * The channel's URI. 281 */ 282 get URI() { 283 return this.#uri; 284 } 285 286 /** 287 * Gets or sets the original URI for this channel. 288 * 289 * @type {nsIURI} 290 */ 291 get originalURI() { 292 return this.#originalURI; 293 } 294 295 set originalURI(aURI) { 296 this.#originalURI = aURI; 297 } 298 299 /** 300 * Gets the current status of the channel. 301 * 302 * @returns {number} 303 * The channel status (nsresult). 304 */ 305 get status() { 306 return this.#status; 307 } 308 309 /** 310 * Gets or sets the content type of the loaded resource. 311 * 312 * @type {string} 313 */ 314 get contentType() { 315 return this.#contentType; 316 } 317 318 set contentType(aContentType) { 319 this.#contentType = aContentType; 320 } 321 322 /** 323 * Gets or sets the content charset of the loaded resource. 324 * 325 * @type {string} 326 */ 327 get contentCharset() { 328 return this.#contentCharset; 329 } 330 331 set contentCharset(aContentCharset) { 332 this.#contentCharset = aContentCharset; 333 } 334 335 /** 336 * Gets or sets the content length of the loaded resource. 337 * 338 * @type {number} 339 * The content length in bytes, or -1 if unknown. 340 */ 341 get contentLength() { 342 return this.#contentLength; 343 } 344 345 set contentLength(aContentLength) { 346 this.#contentLength = aContentLength; 347 } 348 349 /** 350 * Gets or sets the load flags for this channel. 351 * 352 * @type {number} 353 */ 354 get loadFlags() { 355 return this.#loadFlags; 356 } 357 358 set loadFlags(aLoadFlags) { 359 this.#loadFlags = aLoadFlags; 360 } 361 362 /** 363 * Gets or sets the load info for this channel. 364 * 365 * @type {nsILoadInfo} 366 */ 367 get loadInfo() { 368 return this.#loadInfo; 369 } 370 371 set loadInfo(aLoadInfo) { 372 this.#loadInfo = aLoadInfo; 373 } 374 375 /** 376 * Gets or sets the owner (principal) of this channel. 377 * 378 * @type {nsIPrincipal} 379 */ 380 get owner() { 381 return this.#owner; 382 } 383 384 set owner(aOwner) { 385 this.#owner = aOwner; 386 } 387 388 /** 389 * Gets the security info for this channel. 390 * 391 * @returns {nsITransportSecurityInfo} 392 * The security information for this channel. 393 */ 394 get securityInfo() { 395 return this.#securityInfo; 396 } 397 398 /** 399 * Gets or sets the notification callbacks for this channel. 400 * 401 * @type {nsIInterfaceRequestor} 402 */ 403 get notificationCallbacks() { 404 return this.#notificationCallbacks; 405 } 406 407 set notificationCallbacks(aCallbacks) { 408 this.#notificationCallbacks = aCallbacks; 409 } 410 411 /** 412 * Gets or sets the load group for this channel. 413 * 414 * @type {nsILoadGroup} 415 */ 416 get loadGroup() { 417 return this.#loadGroup; 418 } 419 420 set loadGroup(aLoadGroup) { 421 this.#loadGroup = aLoadGroup; 422 } 423 424 /** 425 * Gets the name of this channel (its URI spec). 426 * 427 * @returns {string} 428 * The channel name. 429 */ 430 get name() { 431 return this.#uri.spec; 432 } 433 434 /** 435 * Checks if this channel has a pending request. 436 * 437 * @returns {boolean} 438 * True if there is a pending request. 439 */ 440 isPending() { 441 return this.#pendingChannel ? this.#pendingChannel.isPending() : false; 442 } 443 444 /** 445 * Cancels the channel with the given status. 446 * 447 * @param {number} status 448 * The status code to cancel with. 449 */ 450 cancel(status) { 451 this.#cancelled = true; 452 this.#status = status; 453 454 if (this.#pendingChannel) { 455 this.#pendingChannel.cancel(status); 456 } 457 } 458 459 /** 460 * Suspends the channel if it has a pending request. 461 */ 462 suspend() { 463 if (this.#pendingChannel) { 464 this.#pendingChannel.suspend(); 465 } 466 } 467 468 /** 469 * Resumes the channel if it has a pending request. 470 */ 471 resume() { 472 if (this.#pendingChannel) { 473 this.#pendingChannel.resume(); 474 } 475 } 476 477 /** 478 * Opens the channel synchronously. Not supported for this protocol. 479 * 480 * @throws {Components.Exception} 481 * Always throws as sync open is not supported. 482 */ 483 open() { 484 throw Components.Exception( 485 "moz-cached-ohttp protocol does not support synchronous open", 486 Cr.NS_ERROR_NOT_IMPLEMENTED 487 ); 488 } 489 490 /** 491 * Opens the channel asynchronously. 492 * 493 * @param {nsIStreamListener} listener 494 * The stream listener to notify. 495 * @throws {Components.Exception} 496 * If the channel was already cancelled. 497 */ 498 asyncOpen(listener) { 499 if (this.#cancelled) { 500 throw Components.Exception("Channel was cancelled", this.#status); 501 } 502 503 this.#listener = listener; 504 505 this.#loadResource().catch(error => { 506 console.error("moz-cached-ohttp channel error:", error); 507 this.#notifyError(Cr.NS_ERROR_FAILURE); 508 }); 509 } 510 511 /** 512 * Loads the requested resource using a cache-first strategy. 513 * First attempts to load from HTTP cache, then falls back to OHTTP if not 514 * cached. 515 * 516 * @returns {Promise<undefined>} 517 * Promise that resolves when loading is complete. 518 */ 519 async #loadResource() { 520 const { resourceURI, host } = this.#extractHostAndResourceURI(); 521 if (!resourceURI) { 522 throw new Error("Invalid moz-cached-ohttp URL format"); 523 } 524 525 // Try cache first (avoid network racing) 526 const wasCached = await this.#tryCache(resourceURI); 527 if (wasCached) { 528 // Nothing to do - we'll be streaming the response from the cache. 529 return; 530 } 531 532 // Fallback to OHTTP 533 if (!HOST_MAP.has(host)) { 534 throw new Error(`Unrecognized moz-cached-ohttp host: ${host}`); 535 } 536 537 await this.#loadViaOHTTP(resourceURI, host); 538 } 539 540 /** 541 * Extracts both the host and target resource URL from the moz-cached-ohttp:// 542 * URL. 543 * 544 * Expected format: moz-cached-ohttp://newtab-image/?url=<encoded-https-url> 545 * 546 * @returns {{resourceURI: nsIURI, host: string}|null} 547 * Object containing extracted data, or null if invalid. 548 * Returns null if URL parsing fails, no 'url' parameter found, 549 * target URL is not HTTPS, or target URL is malformed. 550 * `resourceURI` is the decoded target image URI (HTTPS only). 551 * `host` is the moz-cached-ohttp host (e.g., "newtab-image"). 552 */ 553 #extractHostAndResourceURI() { 554 try { 555 const url = new URL(this.#uri.spec); 556 const host = url.host; 557 const searchParams = new URLSearchParams(url.search); 558 const resourceURLString = searchParams.get("url"); 559 560 if (!resourceURLString) { 561 return null; 562 } 563 564 // Validate that the extracted URL is a valid HTTP/HTTPS URL 565 const resourceURL = new URL(resourceURLString); 566 if (resourceURL.protocol !== "https:") { 567 return null; 568 } 569 570 return { resourceURI: Services.io.newURI(resourceURLString), host }; 571 } catch (e) { 572 return null; 573 } 574 } 575 576 /** 577 * Attempts to load the resource from the HTTP cache without making network 578 * requests. 579 * 580 * @param {nsIURI} resourceURI 581 * The URI of the resource to load from cache 582 * @returns {Promise<boolean>} 583 * Promise that resolves to true if loaded from cache 584 */ 585 async #tryCache(resourceURI) { 586 let result; 587 588 // Bug 1977139: Transferring nsIInputStream currently causes crashes 589 // from in-process child actors, so only use the MozCachedOHTTPChild 590 // if we're running outside of the parent process. If we're running in the 591 // parent process, we can just talk to the parent actor directly. 592 if ( 593 Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT 594 ) { 595 const [parentProcess] = ChromeUtils.getAllDOMProcesses(); 596 const parentActor = parentProcess.getActor("MozCachedOHTTP"); 597 result = await parentActor.tryCache(resourceURI); 598 } else { 599 const childActor = ChromeUtils.domProcessChild.getActor("MozCachedOHTTP"); 600 result = await childActor.sendQuery("tryCache", { 601 uriString: resourceURI.spec, 602 }); 603 } 604 605 if (result.success) { 606 // Stream from parent-provided inputStream 607 const pump = this.#createInputStreamPump(result.inputStream); 608 let headers = new Headers(result.headersObj); 609 this.#applyContentHeaders(headers); 610 611 await this.#streamFromCache(pump, result.inputStream); 612 return true; 613 } 614 615 return false; 616 } 617 618 /** 619 * Examines any response headers that were included with the cache entry or 620 * network response, and sets any internal state to represent those headers. 621 * For now, this is only the Content-Type header. 622 * 623 * @param {Headers} headers 624 * A Headers object that may have some header key value pairs included. 625 */ 626 #applyContentHeaders(headers) { 627 let contentTypeHeader = headers.get("Content-Type"); 628 if (contentTypeHeader) { 629 let charSet = {}; 630 let hadCharSet = {}; 631 this.#contentType = Services.io.parseResponseContentType( 632 contentTypeHeader, 633 charSet, 634 hadCharSet 635 ); 636 637 if (hadCharSet.value) { 638 this.#contentCharset = charSet.value; 639 } 640 } 641 } 642 643 /** 644 * Creates an input stream pump for efficient data streaming. 645 * 646 * @param {nsIInputStream} inputStream 647 * The input stream to pump. 648 * @returns {nsIInputStreamPump} 649 * The configured pump. 650 */ 651 #createInputStreamPump(inputStream) { 652 const pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance( 653 Ci.nsIInputStreamPump 654 ); 655 pump.init(inputStream, 0, 0, false); 656 return pump; 657 } 658 659 /** 660 * Streams data from cache to the client listener. 661 * 662 * @param {nsIInputStreamPump} pump 663 * The input stream pump. 664 * @param {nsIInputStream} inputStream 665 * The input stream for cleanup. 666 * @returns {Promise<boolean>} 667 * Promise that resolves to true on success. 668 */ 669 #streamFromCache(pump, inputStream) { 670 return new Promise((resolve, reject) => { 671 const listener = this.#createCacheStreamListener( 672 pump, 673 inputStream, 674 resolve 675 ); 676 677 try { 678 pump.asyncRead(listener); 679 } catch (error) { 680 this.#safeCloseStream(inputStream); 681 reject(error); 682 } 683 }); 684 } 685 686 /** 687 * Creates a stream listener for cache data streaming. 688 * 689 * @param {nsIInputStreamPump} pump 690 * The pump for channel tracking. 691 * @param {nsIInputStream} inputStream 692 * Input stream for cleanup. 693 * @param {Function} resolve 694 * Promise resolve function. 695 * @returns {object} 696 * Stream listener implementation. 697 */ 698 #createCacheStreamListener(pump, inputStream, resolve) { 699 return { 700 onStartRequest: () => { 701 this.#startedRequest = true; 702 this.#pendingChannel = pump; 703 this.#listener.onStartRequest(this); 704 }, 705 706 onDataAvailable: (request, innerInputStream, offset, count) => { 707 this.#listener.onDataAvailable(this, innerInputStream, offset, count); 708 }, 709 710 onStopRequest: (request, status) => { 711 this.#pendingChannel = null; 712 this.#safeCloseStream(inputStream); 713 714 this.#listener.onStopRequest(this, status); 715 resolve(Components.isSuccessCode(status)); 716 }, 717 }; 718 } 719 720 /** 721 * Safely closes an input stream, ignoring errors. 722 * 723 * @param {nsIInputStream} stream 724 * The stream to close. 725 */ 726 #safeCloseStream(stream) { 727 try { 728 stream.close(); 729 } catch (e) { 730 // Ignore close errors 731 } 732 } 733 734 /** 735 * Loads the resource via Oblivious HTTP when it's not available in cache. 736 * Manually writes the response to the HTTP cache for future requests. 737 * 738 * @param {nsIURI} resourceURI 739 * The URI of the resource to load via OHTTP 740 * @param {string} host 741 * A host matching one of the entries in HOST_MAP 742 * @returns {Promise<void>} Promise that resolves when OHTTP loading is complete 743 */ 744 async #loadViaOHTTP(resourceURI, host) { 745 const { ohttpGatewayConfig, relayURI } = 746 await this.#protocolHandler.getOHTTPGatewayConfigAndRelayURI(host); 747 748 const ohttpChannel = this.#createOHTTPChannel( 749 relayURI, 750 resourceURI, 751 ohttpGatewayConfig 752 ); 753 754 return this.#executeOHTTPRequest(ohttpChannel); 755 } 756 757 /** 758 * Creates and configures an OHTTP channel. 759 * 760 * @param {nsIURI} relayURI 761 * The OHTTP relay URI. 762 * @param {nsIURI} resourceURI 763 * The target resource URI. 764 * @param {Uint8Array} ohttpConfig 765 * The OHTTP configuration. 766 * @returns {nsIChannel} 767 * The configured OHTTP channel. 768 */ 769 #createOHTTPChannel(relayURI, resourceURI, ohttpConfig) { 770 ChromeUtils.releaseAssert( 771 Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT || 772 Services.appinfo.remoteType === 773 lazy.E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE, 774 "moz-cached-ohttp is only allowed in privileged content processes " + 775 "or the main process" 776 ); 777 778 const ohttpChannel = this.#ohttpService.newChannel( 779 relayURI, 780 resourceURI, 781 ohttpConfig 782 ); 783 784 // I'm not sure I love this, but in order for the privileged about content 785 // process to make requests to the relay and not get dinged by 786 // OpaqueResponseBlocking, we need to make the load come from the system 787 // principal. 788 const loadInfo = lazy.NetUtil.newChannel({ 789 uri: relayURI, 790 loadUsingSystemPrincipal: true, 791 contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER, 792 }).loadInfo; 793 794 // Copy relevant properties from our channel to the OHTTP channel 795 ohttpChannel.loadInfo = loadInfo; 796 ohttpChannel.loadFlags = this.#loadFlags | Ci.nsIRequest.LOAD_ANONYMOUS; 797 798 if (this.#notificationCallbacks) { 799 ohttpChannel.notificationCallbacks = this.#notificationCallbacks; 800 } 801 802 if (this.#loadGroup) { 803 ohttpChannel.loadGroup = this.#loadGroup; 804 } 805 806 return ohttpChannel; 807 } 808 809 /** 810 * Executes the OHTTP request with caching support. 811 * 812 * @param {nsIChannel} ohttpChannel 813 * The OHTTP channel to execute. 814 * @returns {Promise<undefined, Error>} 815 * Promise that resolves when request is complete. 816 */ 817 #executeOHTTPRequest(ohttpChannel) { 818 this.#pendingChannel = ohttpChannel; 819 820 let cachePipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe); 821 cachePipe.init( 822 true /* non-blocking input */, 823 false /* blocking output */, 824 0 /* segment size */, 825 0 /* max segments */ 826 ); 827 let cacheStreamUpdate = new MessageChannel(); 828 829 return new Promise((resolve, reject) => { 830 // Bug 1977139: Transferring nsIInputStream currently causes crashes 831 // from in-process child actors, so only use the MozCachedOHTTPChild 832 // if we're running outside of the parent process. If we're running in the 833 // parent process, we can just talk to the parent actor directly. 834 if ( 835 Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT 836 ) { 837 const [parentProcess] = ChromeUtils.getAllDOMProcesses(); 838 const parentActor = parentProcess.getActor("MozCachedOHTTP"); 839 parentActor.writeCache( 840 ohttpChannel.URI, 841 cachePipe.inputStream, 842 cacheStreamUpdate.port2 843 ); 844 } else { 845 const childActor = 846 ChromeUtils.domProcessChild.getActor("MozCachedOHTTP"); 847 childActor.sendAsyncMessage( 848 "writeCache", 849 { 850 uriString: ohttpChannel.URI.spec, 851 cacheInputStream: cachePipe.inputStream, 852 cacheStreamUpdatePort: cacheStreamUpdate.port2, 853 }, 854 [cacheStreamUpdate.port2] 855 ); 856 } 857 858 const originalListener = this.#createOHTTPResponseListener( 859 cacheStreamUpdate.port1, 860 cachePipe.outputStream, 861 resolve, 862 reject 863 ); 864 865 const finalListener = this.#setupStreamTee( 866 originalListener, 867 cachePipe.outputStream 868 ); 869 870 try { 871 ohttpChannel.asyncOpen(finalListener); 872 } catch (error) { 873 this.#cleanupCacheOnError( 874 cacheStreamUpdate.port1, 875 cachePipe.outputStream 876 ); 877 reject(error); 878 } 879 }); 880 } 881 882 /** 883 * Creates a response listener for OHTTP requests with cache handling. 884 * 885 * @param {MessageChannelPort} cacheStreamUpdatePort 886 * MessagePort for sending cache control messages to the parent actor 887 * for operations like setting expiration time and dooming cache entries. 888 * @param {nsIOutputStream} cachePipeOutputStream 889 * Output stream of the pipe used to send response data to the cache 890 * writer in the parent process via the JSActor. 891 * @param {Function} resolve 892 * Promise resolve function. 893 * @param {Function} reject 894 * Promise reject function. 895 * @returns {nsIStreamListener} 896 * Stream listener implementation 897 */ 898 #createOHTTPResponseListener( 899 cacheStreamUpdatePort, 900 cachePipeOutputStream, 901 resolve, 902 reject 903 ) { 904 return { 905 onStartRequest: request => { 906 // Copy content type from the OHTTP response to our channel 907 if (request instanceof Ci.nsIHttpChannel) { 908 try { 909 const contentType = request.getResponseHeader("content-type"); 910 if (contentType) { 911 let headers = new Headers(); 912 headers.set("content-type", contentType); 913 this.#applyContentHeaders(headers); 914 } 915 } catch (e) { 916 // Content-Type header not available 917 } 918 } 919 920 this.#startedRequest = true; 921 this.#listener.onStartRequest(this); 922 this.#processResponseHeaders(cacheStreamUpdatePort, request); 923 this.#processCacheControl(cacheStreamUpdatePort, request); 924 }, 925 926 onDataAvailable: (request, inputStream, offset, count) => { 927 this.#listener.onDataAvailable(this, inputStream, offset, count); 928 }, 929 930 onStopRequest: (request, status) => { 931 this.#pendingChannel = null; 932 this.#finalizeCacheEntry( 933 cacheStreamUpdatePort, 934 cachePipeOutputStream, 935 status 936 ); 937 this.#listener.onStopRequest(this, status); 938 939 if (Components.isSuccessCode(status)) { 940 resolve(); 941 } else { 942 reject(new Error(`OHTTP request failed with status: ${status}`)); 943 } 944 }, 945 }; 946 } 947 948 /** 949 * Sets up stream tee for cache writing if cache output stream is available. 950 * 951 * @param {nsIStreamListener} originalListener 952 * The original stream listener. 953 * @param {nsIOutputStream} cacheOutputStream 954 * Cache output stream (may be null). 955 * @returns {nsIStreamListener} 956 * Final listener (tee or original). 957 */ 958 #setupStreamTee(originalListener, cacheOutputStream) { 959 if (!cacheOutputStream) { 960 return originalListener; 961 } 962 963 try { 964 const tee = Cc[ 965 "@mozilla.org/network/stream-listener-tee;1" 966 ].createInstance(Ci.nsIStreamListenerTee); 967 tee.init(originalListener, cacheOutputStream, null); 968 return tee; 969 } catch (error) { 970 console.warn( 971 "Failed to create stream tee, proceeding without caching:", 972 error 973 ); 974 this.#safeCloseStream(cacheOutputStream); 975 return originalListener; 976 } 977 } 978 979 /** 980 * Finalizes cache entry after response completion. 981 * 982 * @param {MessageChannelPort} cacheStreamUpdatePort 983 * MessagePort for sending cache finalization messages to the parent actor. 984 * Used to doom the cache entry if the response failed. 985 * @param {nsIOutputStream} cacheOutputStream 986 * Output stream to close. 987 * @param {number} status 988 * Response status code. 989 */ 990 #finalizeCacheEntry(cacheStreamUpdatePort, cacheOutputStream, status) { 991 try { 992 if (cacheOutputStream) { 993 cacheOutputStream.closeWithStatus(Cr.NS_BASE_STREAM_CLOSED); 994 } 995 996 if (!Components.isSuccessCode(status)) { 997 cacheStreamUpdatePort.postMessage({ name: "DoomCacheEntry" }); 998 } 999 } catch (error) { 1000 console.warn("Failed to finalize cache entry:", error); 1001 } 1002 } 1003 1004 /** 1005 * Cleans up cache resources on error. 1006 * 1007 * @param {MessageChannelPort} cacheStreamUpdatePort 1008 * MessagePort for sending error cleanup messages to the parent actor. 1009 * Used to doom the cache entry when an error occurs during the request. 1010 * @param {boolean} cacheOutputStream 1011 */ 1012 #cleanupCacheOnError(cacheStreamUpdatePort, cacheOutputStream) { 1013 try { 1014 if (cacheOutputStream) { 1015 cacheOutputStream.closeWithStatus(Cr.NS_BASE_STREAM_CLOSED); 1016 } 1017 cacheStreamUpdatePort.postMessage({ name: "DoomCacheEntry" }); 1018 } catch (e) { 1019 // Ignore cleanup errors 1020 } 1021 } 1022 1023 /** 1024 * Notifies the listener of an error condition. 1025 * 1026 * @param {number} status 1027 * The error status code. 1028 */ 1029 #notifyError(status) { 1030 this.#status = status; 1031 if (this.#listener) { 1032 // Depending on when the error occurred, we may have already started 1033 // the request - but if not, start it. 1034 if (!this.#startedRequest) { 1035 this.#listener.onStartRequest(this); 1036 } 1037 this.#listener.onStopRequest(this, status); 1038 } 1039 } 1040 1041 /** 1042 * Attempts to write the response headers into the cache entry. 1043 * 1044 * @param {MessageChannelPort} cacheStreamUpdatePort 1045 * MessagePort for sending cache response headers to the parent actor. 1046 * @param {nsIHttpChannel} httpChannel 1047 * The HTTP channel with response headers. 1048 */ 1049 #processResponseHeaders(cacheStreamUpdatePort, httpChannel) { 1050 let headersObj = {}; 1051 // nsIHttpChannel is marked as a builtinclass, meaning that it cannot 1052 // be implemented by script, which makes mocking out channels a pain. We 1053 // work around this by assuming that if we're in testing mode, that we've 1054 // been passed an nsIChannel implemented in JS that also happens to have a 1055 // visitResponseHeaders method implemented on it. 1056 if (this.#inTestingMode) { 1057 httpChannel = httpChannel.wrappedJSObject; 1058 } else if (!(httpChannel instanceof Ci.nsIHttpChannel)) { 1059 return; 1060 } 1061 1062 httpChannel.visitResponseHeaders({ 1063 visitHeader(name, value) { 1064 headersObj[name] = value; 1065 }, 1066 }); 1067 1068 cacheStreamUpdatePort.postMessage({ 1069 name: "WriteOriginalResponseHeaders", 1070 headersObj, 1071 }); 1072 } 1073 1074 /** 1075 * Sets appropriate cache expiration metadata based on response headers. 1076 * 1077 * @param {MessageChannelPort} cacheStreamUpdatePort 1078 * MessagePort for sending cache metadata messages to the parent actor. 1079 * Used to set cache expiration time and handle cache-control directives. 1080 * @param {nsIHttpChannel} httpChannel 1081 * The HTTP channel with response headers. 1082 */ 1083 #processCacheControl(cacheStreamUpdatePort, httpChannel) { 1084 if (!(httpChannel instanceof Ci.nsIHttpChannel)) { 1085 return; 1086 } 1087 1088 let expirationTime = null; 1089 1090 // Check for Cache-Control header first (takes precedence) 1091 let cacheControl = null; 1092 try { 1093 cacheControl = httpChannel.getResponseHeader("cache-control"); 1094 } catch (e) { 1095 // Cache-Control header not available, continue to check Expires 1096 } 1097 1098 if (cacheControl) { 1099 let cacheControlParseResult = 1100 Services.io.parseCacheControlHeader(cacheControl); 1101 // Respect max-age directive 1102 if (cacheControlParseResult.maxAge) { 1103 expirationTime = Date.now() + cacheControlParseResult.maxAge * 1000; 1104 } else if ( 1105 cacheControlParseResult.noCache || 1106 cacheControlParseResult.noStore 1107 ) { 1108 // Don't cache if explicitly prohibited 1109 cacheStreamUpdatePort.postMessage({ name: "DoomCacheEntry" }); 1110 return; 1111 } 1112 } 1113 1114 // Fallback to Expires header if Cache-Control max-age not found 1115 if (!expirationTime) { 1116 try { 1117 const expires = httpChannel.getResponseHeader("expires"); 1118 if (expires) { 1119 expirationTime = new Date(expires).getTime(); 1120 } 1121 } catch (e) { 1122 // Expires header not available 1123 } 1124 } 1125 1126 // Set default expiration if no headers found (24 hours) 1127 expirationTime ??= Date.now() + 24 * 60 * 60 * 1000; // 24 hours 1128 1129 // Set the expiration time in cache metadata 1130 cacheStreamUpdatePort.postMessage({ 1131 name: "WriteCacheExpiry", 1132 expiry: Math.floor(expirationTime / 1000), 1133 }); 1134 } 1135 1136 QueryInterface = ChromeUtils.generateQI(["nsIChannel"]); 1137 }