FaviconLoader.sys.mjs (20508B)
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 // Bug 1924775 - ESLint doesn't yet know about `ImageDecoder`. 6 /* globals ImageDecoder:false */ 7 8 import { 9 TYPE_SVG, 10 TYPE_ICO, 11 TRUSTED_FAVICON_SCHEMES, 12 blobAsDataURL, 13 } from "moz-src:///browser/modules/FaviconUtils.sys.mjs"; 14 15 const lazy = {}; 16 17 ChromeUtils.defineESModuleGetters(lazy, { 18 DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", 19 }); 20 21 const STREAM_SEGMENT_SIZE = 4096; 22 const PR_UINT32_MAX = 0xffffffff; 23 24 const BinaryInputStream = Components.Constructor( 25 "@mozilla.org/binaryinputstream;1", 26 "nsIBinaryInputStream", 27 "setInputStream" 28 ); 29 const StorageStream = Components.Constructor( 30 "@mozilla.org/storagestream;1", 31 "nsIStorageStream", 32 "init" 33 ); 34 const BufferedOutputStream = Components.Constructor( 35 "@mozilla.org/network/buffered-output-stream;1", 36 "nsIBufferedOutputStream", 37 "init" 38 ); 39 40 const SIZES_TELEMETRY_ENUM = { 41 NO_SIZES: 0, 42 ANY: 1, 43 DIMENSION: 2, 44 INVALID: 3, 45 }; 46 47 const FAVICON_PARSING_TIMEOUT = 100; 48 const FAVICON_RICH_ICON_MIN_WIDTH = 96; 49 const PREFERRED_WIDTH = 16; 50 51 const MAX_FAVICON_EXPIRATION = 7 * 24 * 60 * 60 * 1000; 52 const MAX_ICON_SIZE = 2048; 53 54 async function decodeImage({ 55 url, 56 type, 57 data, 58 transfer, 59 desiredWidth, 60 desiredHeight, 61 }) { 62 let image; 63 try { 64 let decoder = new ImageDecoder({ 65 type, 66 data, 67 desiredWidth, 68 desiredHeight, 69 transfer: transfer ? [data] : undefined, 70 }); 71 72 let result = await decoder.decode({ completeFramesOnly: true }); 73 image = result.image; 74 } catch { 75 throw Components.Exception( 76 `Favicon at "${url}" could not be decoded.`, 77 Cr.NS_ERROR_FAILURE 78 ); 79 } 80 81 if ( 82 image.displayWidth > MAX_ICON_SIZE || 83 image.displayHeight > MAX_ICON_SIZE 84 ) { 85 throw Components.Exception( 86 `Favicon at "${url}" is too large.`, 87 Cr.NS_ERROR_FAILURE 88 ); 89 } 90 91 let imageBuffer = new ArrayBuffer(image.allocationSize()); 92 await image.copyTo(imageBuffer); 93 return { 94 blob: new Blob([imageBuffer]), 95 format: image.format, 96 displayWidth: image.displayWidth, 97 displayHeight: image.displayHeight, 98 }; 99 } 100 101 // Convert image data bytes to an array of blobs with associated format/size info. 102 async function convertImage(url, type, data) { 103 if (type == TYPE_ICO) { 104 try { 105 let decoder = new ImageDecoder({ 106 type, 107 data, 108 }); 109 await decoder.tracks.ready; 110 let sizes = decoder.tracks[0].getSizes(); 111 if (sizes.length > 1) { 112 return Promise.all( 113 sizes.map(({ width, height }) => 114 decodeImage({ 115 url, 116 type, 117 // Can't transfer the data buffer, because we decode multiple times. 118 transfer: false, 119 data, 120 // Decode the ICO image at the different sizes of contained images. 121 desiredWidth: width, 122 desiredHeight: height, 123 }) 124 ) 125 ); 126 } 127 } catch {} 128 } 129 130 let image = await decodeImage({ 131 url, 132 type, 133 transfer: true, 134 data, 135 }); 136 return [image]; 137 } 138 139 class FaviconLoad { 140 constructor(iconInfo) { 141 this.icon = iconInfo; 142 143 let securityFlags; 144 if (iconInfo.node.crossOrigin === "anonymous") { 145 securityFlags = Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT; 146 } else if (iconInfo.node.crossOrigin === "use-credentials") { 147 securityFlags = 148 Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT | 149 Ci.nsILoadInfo.SEC_COOKIES_INCLUDE; 150 } else { 151 securityFlags = 152 Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT; 153 } 154 155 this.channel = Services.io.newChannelFromURI( 156 iconInfo.iconUri, 157 iconInfo.node, 158 iconInfo.node.nodePrincipal, 159 iconInfo.node.nodePrincipal, 160 securityFlags | 161 Ci.nsILoadInfo.SEC_ALLOW_CHROME | 162 Ci.nsILoadInfo.SEC_DISALLOW_SCRIPT, 163 Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON 164 ); 165 166 if (this.channel instanceof Ci.nsIHttpChannel) { 167 this.channel.QueryInterface(Ci.nsIHttpChannel); 168 let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( 169 Ci.nsIReferrerInfo 170 ); 171 // Sometimes node is a document and sometimes it is an element. We need 172 // to set the referrer info correctly either way. 173 if (iconInfo.node.nodeType == iconInfo.node.DOCUMENT_NODE) { 174 referrerInfo.initWithDocument(iconInfo.node); 175 } else { 176 referrerInfo.initWithElement(iconInfo.node); 177 } 178 this.channel.referrerInfo = referrerInfo; 179 } 180 this.channel.loadFlags |= 181 Ci.nsIRequest.LOAD_BACKGROUND | 182 Ci.nsIRequest.VALIDATE_NEVER | 183 Ci.nsIRequest.LOAD_FROM_CACHE; 184 // Sometimes node is a document and sometimes it is an element. This is 185 // the easiest single way to get to the load group in both those cases. 186 this.channel.loadGroup = 187 iconInfo.node.ownerGlobal.document.documentLoadGroup; 188 this.channel.notificationCallbacks = this; 189 190 if (this.channel instanceof Ci.nsIHttpChannelInternal) { 191 this.channel.blockAuthPrompt = true; 192 } 193 194 if ( 195 Services.prefs.getBoolPref("network.http.tailing.enabled", true) && 196 this.channel instanceof Ci.nsIClassOfService 197 ) { 198 this.channel.addClassFlags( 199 Ci.nsIClassOfService.Tail | Ci.nsIClassOfService.Throttleable 200 ); 201 } 202 } 203 204 load() { 205 this._deferred = Promise.withResolvers(); 206 207 // Clear the references when we succeed or fail. 208 let cleanup = () => { 209 this.channel = null; 210 this.dataBuffer = null; 211 this.stream = null; 212 }; 213 this._deferred.promise.then(cleanup, cleanup); 214 215 this.dataBuffer = new StorageStream(STREAM_SEGMENT_SIZE, PR_UINT32_MAX); 216 217 // storage streams do not implement writeFrom so wrap it with a buffered stream. 218 this.stream = new BufferedOutputStream( 219 this.dataBuffer.getOutputStream(0), 220 STREAM_SEGMENT_SIZE * 2 221 ); 222 223 try { 224 this.channel.asyncOpen(this); 225 } catch (e) { 226 this._deferred.reject(e); 227 } 228 229 return this._deferred.promise; 230 } 231 232 cancel() { 233 if (!this.channel) { 234 return; 235 } 236 237 this.channel.cancel(Cr.NS_BINDING_ABORTED); 238 } 239 240 onStartRequest() {} 241 242 onDataAvailable(request, inputStream, offset, count) { 243 this.stream.writeFrom(inputStream, count); 244 } 245 246 asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { 247 if (oldChannel == this.channel) { 248 this.channel = newChannel; 249 } 250 251 callback.onRedirectVerifyCallback(Cr.NS_OK); 252 } 253 254 async onStopRequest(request, statusCode) { 255 if (request != this.channel) { 256 // Indicates that a redirect has occurred. We don't care about the result 257 // of the original channel. 258 return; 259 } 260 261 this.stream.close(); 262 this.stream = null; 263 264 if (!Components.isSuccessCode(statusCode)) { 265 if (statusCode == Cr.NS_BINDING_ABORTED) { 266 this._deferred.reject( 267 Components.Exception( 268 `Favicon load from ${this.icon.iconUri.spec} was cancelled.`, 269 statusCode 270 ) 271 ); 272 } else { 273 this._deferred.reject( 274 Components.Exception( 275 `Favicon at "${this.icon.iconUri.spec}" failed to load.`, 276 statusCode 277 ) 278 ); 279 } 280 return; 281 } 282 283 if (this.channel instanceof Ci.nsIHttpChannel) { 284 if (!this.channel.requestSucceeded) { 285 this._deferred.reject( 286 Components.Exception( 287 `Favicon at "${this.icon.iconUri.spec}" failed to load: ${this.channel.responseStatusText}.`, 288 { data: { httpStatus: this.channel.responseStatus } } 289 ) 290 ); 291 return; 292 } 293 } 294 295 // By default we don't store icons added after the `pageshow` event as they 296 // may be used to show a badge, indicate a service status, or other form 297 // of icon animations. 298 let canStoreIcon = this.icon.beforePageShow; 299 // We make an exception for root icons, as they are unlikely to be used 300 // as status indicators, and in general they are always usable. 301 if (this.icon.iconUri.filePath == "/favicon.ico") { 302 canStoreIcon = true; 303 } else { 304 // Do not store non-root icons if `Cache-Control: no-store` header is set. 305 try { 306 if ( 307 this.channel instanceof Ci.nsIHttpChannel && 308 this.channel.isNoStoreResponse() 309 ) { 310 canStoreIcon = false; 311 } 312 } catch (ex) { 313 if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) { 314 throw ex; 315 } 316 } 317 } 318 319 // Attempt to get an expiration time from the cache. If this fails, we'll 320 // use this default. 321 let expiration = Date.now() + MAX_FAVICON_EXPIRATION; 322 323 // This stuff isn't available after onStopRequest returns (so don't start 324 // any async operations before this!). 325 if (this.channel instanceof Ci.nsICacheInfoChannel) { 326 try { 327 expiration = Math.min( 328 this.channel.cacheTokenExpirationTime * 1000, 329 expiration 330 ); 331 } catch (e) { 332 // Ignore failures to get the expiration time. 333 } 334 } 335 336 try { 337 let stream = new BinaryInputStream(this.dataBuffer.newInputStream(0)); 338 let buffer = new ArrayBuffer(this.dataBuffer.length); 339 stream.readArrayBuffer(buffer.byteLength, buffer); 340 341 let type = this.channel.contentType; 342 let images, dataURL; 343 if (type != "image/svg+xml") { 344 let octets = new Uint8Array(buffer); 345 let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance( 346 Ci.nsIContentSniffer 347 ); 348 type = sniffer.getMIMETypeFromContent( 349 this.channel, 350 octets, 351 octets.length 352 ); 353 354 if (!type) { 355 throw Components.Exception( 356 `Favicon at "${this.icon.iconUri.spec}" did not match a known mimetype.`, 357 Cr.NS_ERROR_FAILURE 358 ); 359 } 360 361 images = await convertImage(this.icon.iconUri.spec, type, buffer); 362 } else { 363 dataURL = await blobAsDataURL(new Blob([buffer], { type })); 364 } 365 366 this._deferred.resolve({ 367 expiration, 368 images, 369 dataURL, 370 canStoreIcon, 371 }); 372 } catch (e) { 373 this._deferred.reject(e); 374 } 375 } 376 377 getInterface(iid) { 378 if (iid.equals(Ci.nsIChannelEventSink)) { 379 return this; 380 } 381 throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); 382 } 383 } 384 385 /** 386 * Extract the icon width from the size attribute. It also sends the telemetry 387 * about the size type and size dimension info. 388 * 389 * @param {Array} aSizes An array of strings about size. 390 * @return {number} A width of the icon in pixel. 391 */ 392 function extractIconSize(aSizes) { 393 let width = -1; 394 let sizesType; 395 const re = /^([1-9][0-9]*)x[1-9][0-9]*$/i; 396 397 if (aSizes.length) { 398 for (let size of aSizes) { 399 if (size.toLowerCase() == "any") { 400 sizesType = SIZES_TELEMETRY_ENUM.ANY; 401 break; 402 } else { 403 let values = re.exec(size); 404 if (values && values.length > 1) { 405 sizesType = SIZES_TELEMETRY_ENUM.DIMENSION; 406 width = parseInt(values[1]); 407 break; 408 } else { 409 sizesType = SIZES_TELEMETRY_ENUM.INVALID; 410 break; 411 } 412 } 413 } 414 } else { 415 sizesType = SIZES_TELEMETRY_ENUM.NO_SIZES; 416 } 417 418 // Telemetry probes for measuring the sizes attribute 419 // usage and available dimensions. 420 Glean.linkIconSizesAttr.usage.accumulateSingleSample(sizesType); 421 if (width > 0) { 422 Glean.linkIconSizesAttr.dimension.accumulateSingleSample(width); 423 } 424 425 return width; 426 } 427 428 /** 429 * Get link icon URI from a link dom node. 430 * 431 * @param {DOMNode} aLink A link dom node. 432 * @return {nsIURI} A uri of the icon. 433 */ 434 function getLinkIconURI(aLink) { 435 let targetDoc = aLink.ownerDocument; 436 let uri = Services.io.newURI(aLink.href, targetDoc.characterSet); 437 try { 438 uri = uri.mutate().setUserPass("").finalize(); 439 } catch (e) { 440 // some URIs are immutable 441 } 442 return uri; 443 } 444 445 /** 446 * Guess a type for an icon based on its declared type or file extension. 447 */ 448 function guessType(icon) { 449 // No type with no icon 450 if (!icon) { 451 return ""; 452 } 453 454 // Use the file extension to guess at a type we're interested in 455 if (!icon.type) { 456 let extension = icon.iconUri.filePath.split(".").pop(); 457 switch (extension) { 458 case "ico": 459 return TYPE_ICO; 460 case "svg": 461 return TYPE_SVG; 462 } 463 } 464 465 // Fuzzily prefer the type or fall back to the declared type 466 return icon.type == "image/vnd.microsoft.icon" ? TYPE_ICO : icon.type || ""; 467 } 468 469 /** 470 * Selects the best rich icon and tab icon from a list of IconInfo objects. 471 * 472 * @param {Array} iconInfos A list of IconInfo objects. 473 * @param {integer} preferredWidth The preferred width for tab icons. 474 */ 475 function selectIcons(iconInfos, preferredWidth) { 476 if (!iconInfos.length) { 477 return { 478 richIcon: null, 479 tabIcon: null, 480 }; 481 } 482 483 let preferredIcon; 484 let bestSizedIcon; 485 // Other links with the "icon" tag are the default icons 486 let defaultIcon; 487 // Rich icons are either apple-touch or fluid icons, or the ones of the 488 // dimension 96x96 or greater 489 let largestRichIcon; 490 491 for (let icon of iconInfos) { 492 if (!icon.isRichIcon) { 493 // First check for svg. If it's not available check for an icon with a 494 // size adapt to the current resolution. If both are not available, prefer 495 // ico files. When multiple icons are in the same set, the latest wins. 496 if (guessType(icon) == TYPE_SVG) { 497 preferredIcon = icon; 498 } else if ( 499 icon.width == preferredWidth && 500 guessType(preferredIcon) != TYPE_SVG 501 ) { 502 preferredIcon = icon; 503 } else if ( 504 guessType(icon) == TYPE_ICO && 505 (!preferredIcon || guessType(preferredIcon) == TYPE_ICO) 506 ) { 507 preferredIcon = icon; 508 } 509 510 // Check for an icon larger yet closest to preferredWidth, that can be 511 // downscaled efficiently. 512 if ( 513 icon.width >= preferredWidth && 514 (!bestSizedIcon || bestSizedIcon.width >= icon.width) 515 ) { 516 bestSizedIcon = icon; 517 } 518 } 519 520 // Note that some sites use hi-res icons without specifying them as 521 // apple-touch or fluid icons. 522 if (icon.isRichIcon || icon.width >= FAVICON_RICH_ICON_MIN_WIDTH) { 523 if (!largestRichIcon || largestRichIcon.width < icon.width) { 524 largestRichIcon = icon; 525 } 526 } else { 527 defaultIcon = icon; 528 } 529 } 530 531 // Now set the favicons for the page in the following order: 532 // 1. Set the best rich icon if any. 533 // 2. Set the preferred one if any, otherwise check if there's a better 534 // sized fit. 535 // This order allows smaller icon frames to eventually override rich icon 536 // frames. 537 538 let tabIcon = null; 539 if (preferredIcon) { 540 tabIcon = preferredIcon; 541 } else if (bestSizedIcon) { 542 tabIcon = bestSizedIcon; 543 } else if (defaultIcon) { 544 tabIcon = defaultIcon; 545 } 546 547 return { 548 richIcon: largestRichIcon, 549 tabIcon, 550 }; 551 } 552 553 class IconLoader { 554 constructor(actor) { 555 this.actor = actor; 556 } 557 558 async load(iconInfo) { 559 if (this._loader) { 560 // If we're already loading this icon, just let it finish. 561 if (this._loader.icon.iconUri.equals(iconInfo.iconUri)) { 562 return; 563 } 564 this._loader.cancel(); 565 } 566 567 if (TRUSTED_FAVICON_SCHEMES.includes(iconInfo.iconUri.scheme)) { 568 // We need to do a manual security check because the channel won't do 569 // it for us. 570 try { 571 Services.scriptSecurityManager.checkLoadURIWithPrincipal( 572 iconInfo.node.nodePrincipal, 573 iconInfo.iconUri, 574 Services.scriptSecurityManager.ALLOW_CHROME 575 ); 576 } catch (ex) { 577 return; 578 } 579 this.actor.sendAsyncMessage("Link:SetIcon", { 580 pageURL: iconInfo.pageUri.spec, 581 originalURL: iconInfo.iconUri.spec, 582 expiration: undefined, 583 iconURL: iconInfo.iconUri.spec, 584 canStoreIcon: 585 iconInfo.beforePageShow && iconInfo.iconUri.schemeIs("data"), 586 beforePageShow: iconInfo.beforePageShow, 587 isRichIcon: iconInfo.isRichIcon, 588 }); 589 return; 590 } 591 592 // Let the main process that a tab icon is possibly coming. 593 this.actor.sendAsyncMessage("Link:LoadingIcon", { 594 originalURL: iconInfo.iconUri.spec, 595 isRichIcon: iconInfo.isRichIcon, 596 }); 597 598 try { 599 this._loader = new FaviconLoad(iconInfo); 600 let { dataURL, images, expiration, canStoreIcon } = 601 await this._loader.load(); 602 603 this.actor.sendAsyncMessage("Link:SetIcon", { 604 pageURL: iconInfo.pageUri.spec, 605 originalURL: iconInfo.iconUri.spec, 606 expiration, 607 iconURL: dataURL, 608 images, 609 canStoreIcon, 610 beforePageShow: iconInfo.beforePageShow, 611 isRichIcon: iconInfo.isRichIcon, 612 }); 613 } catch (e) { 614 if (e.result != Cr.NS_BINDING_ABORTED) { 615 if (typeof e.data?.wrappedJSObject?.httpStatus !== "number") { 616 console.error(e); 617 } 618 619 // Used mainly for tests currently. 620 this.actor.sendAsyncMessage("Link:SetFailedIcon", { 621 originalURL: iconInfo.iconUri.spec, 622 isRichIcon: iconInfo.isRichIcon, 623 }); 624 } 625 } finally { 626 this._loader = null; 627 } 628 } 629 630 cancel() { 631 if (!this._loader) { 632 return; 633 } 634 635 this._loader.cancel(); 636 this._loader = null; 637 } 638 } 639 640 export class FaviconLoader { 641 constructor(actor) { 642 this.actor = actor; 643 this.iconInfos = []; 644 645 // Icons added after onPageShow() are likely added by modifying <link> tags 646 // through javascript; we want to avoid storing those permanently because 647 // they are probably used to show badges, and many of them could be 648 // randomly generated. This boolean can be used to track that case. 649 this.beforePageShow = true; 650 651 // For every page we attempt to find a rich icon and a tab icon. These 652 // objects take care of the load process for each. 653 this.richIconLoader = new IconLoader(actor); 654 this.tabIconLoader = new IconLoader(actor); 655 656 this.iconTask = new lazy.DeferredTask( 657 () => this.loadIcons(), 658 FAVICON_PARSING_TIMEOUT 659 ); 660 } 661 662 loadIcons() { 663 // If the page is unloaded immediately after the DeferredTask's timer fires 664 // we can still attempt to load icons, which will fail since the content 665 // window is no longer available. Checking if iconInfos has been cleared 666 // allows us to bail out early in this case. 667 if (!this.iconInfos.length) { 668 return; 669 } 670 671 let preferredWidth = 672 PREFERRED_WIDTH * Math.ceil(this.actor.contentWindow.devicePixelRatio); 673 let { richIcon, tabIcon } = selectIcons(this.iconInfos, preferredWidth); 674 this.iconInfos = []; 675 676 if (richIcon) { 677 this.richIconLoader.load(richIcon).catch(console.error); 678 } 679 680 if (tabIcon) { 681 this.tabIconLoader.load(tabIcon).catch(console.error); 682 } 683 } 684 685 addIconFromLink(aLink, aIsRichIcon) { 686 let iconInfo = makeFaviconFromLink(aLink, aIsRichIcon); 687 if (iconInfo) { 688 iconInfo.beforePageShow = this.beforePageShow; 689 this.iconInfos.push(iconInfo); 690 this.iconTask.arm(); 691 return true; 692 } 693 return false; 694 } 695 696 addDefaultIcon(pageUri) { 697 // Currently ImageDocuments will just load the default favicon, see bug 698 // 403651 for discussion. 699 this.iconInfos.push({ 700 pageUri, 701 iconUri: pageUri.mutate().setPathQueryRef("/favicon.ico").finalize(), 702 width: -1, 703 isRichIcon: false, 704 type: TYPE_ICO, 705 node: this.actor.document, 706 beforePageShow: this.beforePageShow, 707 }); 708 this.iconTask.arm(); 709 } 710 711 onPageShow() { 712 // We're likely done with icon parsing so load the pending icons now. 713 if (this.iconTask.isArmed) { 714 this.iconTask.disarm(); 715 this.loadIcons(); 716 } 717 this.beforePageShow = false; 718 } 719 720 onPageHide() { 721 this.richIconLoader.cancel(); 722 this.tabIconLoader.cancel(); 723 724 this.iconTask.disarm(); 725 this.iconInfos = []; 726 } 727 } 728 729 function makeFaviconFromLink(aLink, aIsRichIcon) { 730 let iconUri = getLinkIconURI(aLink); 731 if (!iconUri) { 732 return null; 733 } 734 735 // Extract the size type and width. 736 let width = extractIconSize(aLink.sizes); 737 738 return { 739 pageUri: aLink.ownerDocument.documentURIObject, 740 iconUri, 741 width, 742 isRichIcon: aIsRichIcon, 743 type: aLink.type, 744 node: aLink, 745 }; 746 }