From d540c6cddfd46f2739f88afc7246adc72d85dd15 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:33:01 +0200 Subject: [PATCH] no-bug: Block loading glance from unmatching principals (gh-13237) --- src/zen/glance/ZenGlanceManager.mjs | 181 +++++++++++-------- src/zen/glance/actors/ZenGlanceChild.sys.mjs | 85 ++++++--- src/zen/glance/zen-glance.css | 5 +- 3 files changed, 165 insertions(+), 106 deletions(-) diff --git a/src/zen/glance/ZenGlanceManager.mjs b/src/zen/glance/ZenGlanceManager.mjs index a63ccb234..0f3112570 100644 --- a/src/zen/glance/ZenGlanceManager.mjs +++ b/src/zen/glance/ZenGlanceManager.mjs @@ -34,7 +34,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { // Arc animation configuration #ARC_CONFIG = Object.freeze({ - ARC_STEPS: 400, // Increased for smoother bounce + ARC_STEPS: 80, // Browser interpolates between keyframes natively MAX_ARC_HEIGHT: 25, ARC_HEIGHT_RATIO: 0.2, // Arc height = distance * ratio (capped at MAX_ARC_HEIGHT) }); @@ -78,7 +78,11 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { menuitem.setAttribute("data-l10n-id", "zen-open-link-in-glance"); menuitem.addEventListener("command", () => - this.openGlance({ url: gContextMenu.linkURL }) + this.openGlance({ + url: gContextMenu.linkURL, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }) ); document @@ -171,19 +175,20 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { /** * Create a new browser element for a glance * - * @param {string} url - The URL to load + * @param {object} data - Glance data including URL and dimensions * @param {Tab} currentTab - The current tab - * @param {Tab} existingTab - Optional existing tab to reuse + * @param {Tab|null} existingTab - Optional existing tab to reuse * @returns {Browser} The created browser element */ - #createBrowserElement(url, currentTab, existingTab = null) { - const newTabOptions = this.#createTabOptions(currentTab); + #createBrowserElement(data, currentTab, existingTab = null) { + const url = data.url; + const newTabOptions = this.#createTabOptions(currentTab, data); const newUUID = gZenUIManager.generateUuidv4(); currentTab._selected = true; const newTab = existingTab ?? - gBrowser.addTrustedTab(Services.io.newURI(url).spec, newTabOptions); + gBrowser.addTab(Services.io.newURI(url).spec, newTabOptions); this.#configureNewTab(newTab, currentTab, newUUID); this.#registerGlance(newTab, currentTab, newUUID); @@ -196,14 +201,18 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { * Create tab options for a new glance tab * * @param {Tab} currentTab - The current tab + * @param {object} data - Glance data for the new tab * @returns {object} Tab options */ - #createTabOptions(currentTab) { + #createTabOptions(currentTab, data) { return { userContextId: currentTab.getAttribute("usercontextid") || "", skipBackgroundNotify: true, insertTab: true, skipLoad: false, + skipAnimation: true, + ownerTab: currentTab, + triggeringPrincipal: data.triggeringPrincipal, }; } @@ -364,7 +373,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.#setAnimationState(true); const currentTab = ownerTab ?? gBrowser.selectedTab; const browserElement = this.#createBrowserElement( - data.url, + data, currentTab, existingTab ); @@ -393,7 +402,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { * @returns {Promise} Promise that resolves to the glance tab */ #animateGlanceOpening(data, browserElement) { - this.#prepareGlanceAnimation(data, browserElement); + this.#prepareGlanceAnimation(data); // FIXME(cheffy): We *must* have the call back async (at least, // until a better solution is found). If we do it inside the requestAnimationFrame, // we see flashing and if we do it directly, the animation does not play at all. @@ -417,15 +426,13 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { * Prepare the glance for animation * * @param {object} data - Glance data - * @param {Browser} browserElement - The browser element */ - #prepareGlanceAnimation(data, browserElement) { + #prepareGlanceAnimation(data) { this.quickOpenGlance(); const newButtons = this.#createNewOverlayButtons(); this.browserWrapper.appendChild(newButtons); this.#setupGlancePositioning(data); - this.#configureBrowserElement(browserElement); } /** @@ -463,9 +470,6 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.overlay.removeAttribute("fade-out"); this.browserWrapper.setAttribute("animate", true); - this.browserWrapper.style.transform = `translate(${left - width / 2}px, ${top - height / 2}px)`; - this.browserWrapper.style.width = `${width}px`; - this.browserWrapper.style.height = `${height}px`; this.#storeOriginalPosition({ top, left, width, height }); this.overlay.style.overflow = "visible"; @@ -510,33 +514,6 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { return imageDataElement; } - /** - * Configure browser element for animation - * - * @param {Browser} browserElement - The browser element - */ - #configureBrowserElement(browserElement) { - const rect = window.windowUtils.getBoundsWithoutFlushing( - this.browserWrapper.parentElement - ); - const minWidth = rect.width * 0.8; - const minHeight = rect.height * 0.8; - - browserElement.style.minWidth = `${minWidth}px`; - browserElement.style.minHeight = `${minHeight}px`; - } - - /** - * Get the transform origin for the animation - * - * @param {object} data - Glance data with position and dimensions - * @returns {string} The transform origin CSS value - */ - #getTransformOrigin(data) { - const { clientX, clientY } = data; - return `${clientX}px ${clientY}px`; - } - /** * Execute the main glance animation * @@ -547,11 +524,13 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { #executeGlanceAnimation(data, browserElement, resolve) { const imageDataElement = this.#handleElementPreview(data); - // Create curved animation sequence - const arcSequence = this.#createGlanceArcSequence(data, "opening"); - const transformOrigin = this.#getTransformOrigin(data); - - this.browserWrapper.style.transformOrigin = transformOrigin; + // Create the curved animation sequence. The transform origin is handled + // separately (for example via CSS on the wrapper). + const arcSequence = this.#createGlanceArcSequence( + data, + "opening", + imageDataElement + ); // Only animate if there is element data, so we can apply a // nice fade-in effect to the content. But if it doesn't exist, @@ -601,10 +580,41 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { * * @param {object} data - Glance data with position and dimensions * @param {string} direction - 'opening' or 'closing' + * @param {Element|null} imageDataElement - The image data element for preview (optional) * @returns {object} Animation sequence object */ - #createGlanceArcSequence(data, direction) { - const { clientX, clientY, width, height } = data; + #createGlanceArcSequence(data, direction, imageDataElement = null) { + let { clientX, clientY, width, height } = data; + if (imageDataElement?.parentElement) { + // Since we are animating scale transforms on the wrapper, we need to + // adjust the width/height to match the scaled size of the element preview, + // so the image preview properly matches the size of the animating browser + // during the animation. + // For example: + // +-- wrapper --------------------------+ + // | | + // | +--- element preview -------------+ | + // | | | | + // | +---------------------------------+ | + // | | + // +-------------------------------------+ + // We are scaling the wrapper while having only the element preview size + // in mind, so we need to adjust the width/height to match the size of the element preview + const rect = imageDataElement.getBoundingClientRect(); + const imageRect = + imageDataElement.firstElementChild.getBoundingClientRect(); + const widthRatio = rect.width / imageRect.width; + // Since the image hasn't loaded at this point, so the image's height is 0 + // we need to calculate the height ratio based on the original aspect ratio of the image + const aspectRatio = width / height; + const heightRatio = rect.height / (rect.width / aspectRatio); + const originalWidth = width; + const originalHeight = height; + width *= widthRatio; + height *= heightRatio; + clientX -= (width - originalWidth) / 2; + clientY -= (height - originalHeight) / 2; + } // Calculate start and end positions based on direction let startPosition, endPosition; @@ -643,6 +653,12 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { }; } + // Reference size used as the scale(1, 1) baseline — this matches the + // wrapper's natural CSS size (80% x 100% of the tab panels) so the + // animation can run entirely on the compositor via transform. + const refWidth = tabPanelsRect.width * widthPercent; + const refHeight = tabPanelsRect.height; + // Calculate distance and arc parameters const distance = this.#calculateDistance(startPosition, endPosition); const { arcHeight, shouldArcDownward } = this.#calculateOptimalArc( @@ -652,9 +668,10 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { ); const sequence = { - transform: [], - width: [], - height: [], + x: [], + y: [], + scaleY: [], + scaleX: [], }; const steps = this.#ARC_CONFIG.ARC_STEPS; @@ -684,6 +701,8 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { const currentHeight = startPosition.height + (endPosition.height - startPosition.height) * eased; + const scaleX = currentWidth / refWidth; + const scaleY = currentHeight / refHeight; // Calculate position on arc const distanceX = endPosition.x - startPosition.x; @@ -695,11 +714,12 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { distanceY * eased + arcDirection * arcHeight * (1 - (2 * eased - 1) ** 2); - sequence.transform.push( - `translate(${x - currentWidth / 2}px, ${y - currentHeight / 2}px)` - ); - sequence.width.push(`${currentWidth}px`); - sequence.height.push(`${currentHeight}px`); + let translateX = x - currentWidth / 2; + let translateY = y - currentHeight / 2; + sequence.x.push(translateX); + sequence.y.push(translateY); + sequence.scaleX.push(scaleX); + sequence.scaleY.push(scaleY); } return sequence; @@ -763,19 +783,16 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { imageDataElement.remove(); } + // Batch all style/attribute writes together to avoid interleaved + // read/write layout thrashing. this.browserWrapper.style.transformOrigin = ""; - - browserElement.style.minWidth = ""; - browserElement.style.minHeight = ""; - this.browserWrapper.style.height = "100%"; this.browserWrapper.style.width = "80%"; - - gBrowser.tabContainer._invalidateCachedTabs(); - this.overlay.style.removeProperty("overflow"); this.browserWrapper.removeAttribute("animate"); this.browserWrapper.setAttribute("has-finished-animation", true); + this.overlay.style.removeProperty("overflow"); + gBrowser.tabContainer._invalidateCachedTabs(); this.#setAnimationState(false); this.#currentTab.dispatchEvent(new Event("GlanceOpen", { bubbles: true })); resolve(this.#currentTab); @@ -970,19 +987,14 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { } } - #imageBitmapToBase64(imageBitmap) { - // 1. Create a canvas with the same size as the ImageBitmap - const canvas = document.createElement("canvas"); - canvas.width = imageBitmap.width; - canvas.height = imageBitmap.height; - - // 2. Draw the ImageBitmap onto the canvas + async #imageBitmapToBase64(imageBitmap) { + // Use OffscreenCanvas + blob URL to avoid blocking the main thread + // with synchronous base64 encoding from toDataURL(). + const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height); const ctx = canvas.getContext("2d"); ctx.drawImage(imageBitmap, 0, 0); - - // 3. Convert the canvas content to a Base64 string (PNG by default) - const base64String = canvas.toDataURL("image/png"); - return base64String; + const blob = await canvas.convertToBlob({ type: "image/png" }); + return URL.createObjectURL(blob); } /** @@ -1027,12 +1039,20 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.#currentGlanceID ).elementImageData; - this.#addElementPreview(elementImageData); + const imageDataElement = this.#addElementPreview(elementImageData); // Create curved closing animation sequence const closingData = this.#createClosingDataFromOriginalPosition(originalPosition); - const arcSequence = this.#createGlanceArcSequence(closingData, "closing"); + const arcSequence = this.#createGlanceArcSequence( + closingData, + "closing", + imageDataElement + ); + + // Batch style writes before starting animation to avoid layout thrashing + this.browserWrapper.style.width = ""; + this.browserWrapper.style.height = ""; gZenUIManager.motion .animate(this.browserWrapper, arcSequence, { @@ -1084,6 +1104,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { const imageDataElement = this.#createGlancePreviewElement(elementImageData); this.browserWrapper.prepend(imageDataElement); + return imageDataElement; } } @@ -1506,6 +1527,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.openGlance( { url: undefined, + // No need for triggeringPrincipal here }, tab, tab.owner @@ -1692,6 +1714,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { clientY: top, width: rect.width, height: rect.height, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), }; } @@ -1871,6 +1894,8 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { ...clickPosition, width: 0, height: 0, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), }, currentTab, parentTab diff --git a/src/zen/glance/actors/ZenGlanceChild.sys.mjs b/src/zen/glance/actors/ZenGlanceChild.sys.mjs index 6583c9ef2..45a3b520a 100644 --- a/src/zen/glance/actors/ZenGlanceChild.sys.mjs +++ b/src/zen/glance/actors/ZenGlanceChild.sys.mjs @@ -2,6 +2,21 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "blockJavascript", + "browser.link.alternative_click.block_javascript", + true +); + export class ZenGlanceChild extends JSWindowActorChild { #activationMethod; @@ -26,31 +41,27 @@ export class ZenGlanceChild extends JSWindowActorChild { return !(event.ctrlKey ^ event.altKey ^ event.shiftKey ^ event.metaKey); } - #openGlance(target) { - let url = target.href; - // Add domain to relative URLs - if (!url.match(/^(?:[a-z]+:)?\/\//i)) { - url = this.contentWindow.location.origin + url; - } + #openGlance(href, principal) { this.sendAsyncMessage("ZenGlance:OpenGlance", { - url, + url: href, + triggeringPrincipal: principal, }); } - #sendClickDataToParent(target, element) { - if (!element && !target) { + #sendClickDataToParent(node, originalTarget) { + if (!node) { return; } - if (!target) { - target = element; - } // Get the largest element we can get. If the `A` element // is a parent of the original target, use the anchor element, // otherwise use the original target. - let rect = element.getBoundingClientRect(); - const anchorRect = target.getBoundingClientRect(); - if (anchorRect.width * anchorRect.height > rect.width * rect.height) { - rect = anchorRect; + let rect = node.getBoundingClientRect(); + const originalTargetRect = originalTarget.getBoundingClientRect(); + if ( + originalTargetRect.width * originalTargetRect.height > + rect.width * rect.height + ) { + rect = originalTargetRect; } this.sendAsyncMessage("ZenGlance:RecordLinkClickData", { clientX: rect.left, @@ -68,29 +79,50 @@ export class ZenGlanceChild extends JSWindowActorChild { */ #getTargetFromEvent(event) { // get closest A element - const target = event.target.closest("A"); - const elementToRecord = event.originalTarget || event.target; + let [href, node, principal] = + lazy.BrowserUtils.hrefAndLinkNodeForClickEvent(event); return { - target, - elementToRecord, + href, + node, + principal, }; } + #checkSecurity(href, principal) { + if ( + lazy.blockJavascript && + Services.io.extractScheme(href) == "javascript" + ) { + // We don't want to open new tabs or windows for javascript: links. + return true; + } + + try { + Services.scriptSecurityManager.checkLoadURIStrWithPrincipal( + principal, + href + ); + } catch (e) { + return true; + } + return false; + } + on_mousedown(event) { - const { target, elementToRecord } = this.#getTargetFromEvent(event); + const { node } = this.#getTargetFromEvent(event); // We record the link data anyway, even if the glance may be invoked // or not. We have some cases where glance would open, for example, // when clicking on a link with a different domain where glance would open. // The problem is that at that stage we don't know the rect or even what // element has been clicked, so we send the data here. - this.#sendClickDataToParent(target, elementToRecord); + this.#sendClickDataToParent(node, event.target); } on_click(event) { - const { target } = this.#getTargetFromEvent(event); + const { node, href, principal } = this.#getTargetFromEvent(event); if ( event.button !== 0 || - !target || + !node || event.defaultPrevented || this.#ensureOnlyKeyModifiers(event) ) { @@ -106,9 +138,12 @@ export class ZenGlanceChild extends JSWindowActorChild { } else if (activationMethod === "meta" && !event.metaKey) { return; } + if (this.#checkSecurity(href, principal)) { + return; + } event.preventDefault(); event.stopPropagation(); - this.#openGlance(target); + this.#openGlance(href, principal); } on_keydown(event) { diff --git a/src/zen/glance/zen-glance.css b/src/zen/glance/zen-glance.css index 1257bcdc7..807aea8c2 100644 --- a/src/zen/glance/zen-glance.css +++ b/src/zen/glance/zen-glance.css @@ -120,14 +120,14 @@ } & .browserContainer { - transform: translate(-50%, -50%); position: fixed; flex: unset !important; width: 80%; height: 100%; &:not([has-finished-animation="true"]) { - will-change: width, height, transform; + will-change: transform; + transform-origin: 0 0; #statuspanel { display: none; @@ -178,7 +178,6 @@ top: 50%; left: 50%; translate: -50% -50%; - background: rgba(255, 255, 255, 0.1); display: flex; align-items: center;