From 54f5989e4eac7adb3704c5743e98955dcb280b2e Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 26 Jul 2026 15:19:46 -0700 Subject: [PATCH] Match the popup's messages against a canonical GUI origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web flow compared `event.origin` to `puter.defaultGUIOrigin` as raw strings, but they are different kinds of value: the event carries the browser's canonical origin serialization, while the configured origin is whatever text was supplied — a trailing slash, an explicit default port, or a stray path all name the same origin and all fail the comparison. The mismatch doesn't read as a config error, it reads as the user's answer: with every message from the popup dropped, the missing `permissionPromptReady` makes the popup's close look like a severed opener, and the missing decision leaves that path to answer on its own — "denied", for a guest, moments after the user clicked Allow and the grant committed. Parse the configured origin once and compare canonical-to-canonical; the popup URL is built from the same parsed origin, so a trailing slash no longer yields a `//action/...` path either. A configured origin that cannot parse could never have hosted the prompt, so it now denies up front instead of opening a broken window. Pinned by an e2e test that re-points the SDK at the same GUI through a trailing-slash origin and expects the grant to be heard; it fails against the raw comparison. --- src/puter-js/src/modules/UI.js | 20 +++++++++++-- .../tests/e2e/specs/requestPermission.spec.js | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 4361b6be4..26c12081d 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -1140,9 +1140,25 @@ class UI extends EventListener { // decision message that may still be in flight. const CLOSE_GRACE_MS = 1000; + // The popup's messages arrive tagged with the browser's canonical + // serialization of its origin, while `defaultGUIOrigin` is + // configuration-supplied text — a trailing slash, an explicit default + // port, or a stray path would fail a raw comparison. A dropped message + // here doesn't just hang: an unseen `permissionPromptReady` makes the + // popup's close read as a severed opener, and an unseen decision then + // reports a permission the user granted as denied. Parse once and + // compare canonical-to-canonical. A configured origin that can't parse + // can't host the prompt at all, so deny up front. + let gui_origin; + try { + gui_origin = new URL(puter.defaultGUIOrigin).origin; + } catch (e) { + return false; + } + return new Promise((resolve) => { const msg_id = this.#messageID++; - const url = `${puter.defaultGUIOrigin}/action/request-permission?embedded_in_popup=true&msg_id=${msg_id}&permission=${encodeURIComponent(permission)}`; + const url = `${gui_origin}/action/request-permission?embedded_in_popup=true&msg_id=${msg_id}&permission=${encodeURIComponent(permission)}`; // Guards against settling more than once across the message, // popup-closed, and dialog-cancel code paths. @@ -1181,7 +1197,7 @@ class UI extends EventListener { // the popup we opened. Origin alone is insufficient (any frame // on the GUI domain could post), so also pin event.source. // msg_id binds the message to this request. - if ( e.origin !== puter.defaultGUIOrigin ) return; + if ( e.origin !== gui_origin ) return; if ( popupWindow && e.source !== popupWindow ) return; if ( e.data?.original_msg_id != msg_id ) return; // The popup reporting that it is up and can reach us. Carries diff --git a/src/puter-js/tests/e2e/specs/requestPermission.spec.js b/src/puter-js/tests/e2e/specs/requestPermission.spec.js index cee444371..8f182ecf2 100644 --- a/src/puter-js/tests/e2e/specs/requestPermission.spec.js +++ b/src/puter-js/tests/e2e/specs/requestPermission.spec.js @@ -453,6 +453,35 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => { await expect(page.locator('#log [data-entry="perm:driver:true"]')).toBeVisible(); }); + test('a non-canonical configured origin still hears the decision', async ({ page }) => { + // `defaultGUIOrigin` is configuration-supplied text, while the popup's + // messages arrive tagged with the browser's canonical origin + // serialization. A trailing slash used to fail the raw comparison and + // drop both messages — and a dropped `permissionPromptReady` makes the + // popup's close read as a severed opener, reporting the grant the user + // just made as a denial. The SDK compares canonical-to-canonical now. + await page.goto('/'); + await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 }); + + await page.goto(PERMISSION_FIXTURE_URL); + await page.locator('body.ready').waitFor({ timeout: 60_000 }); + // Re-point the SDK at the same GUI through a non-canonical string. + // The getter reads `globalThis.PUTER_ORIGIN` on every access, so this + // takes effect for the request below. + await page.evaluate(() => { + globalThis.PUTER_ORIGIN = `${window.PUTER_ORIGIN}/`; + }); + + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('#req-driver-perm').click(), + ]); + const dialog = popup.locator('dialog.perm-dialog'); + await expect(dialog).toBeVisible({ timeout: 60_000 }); + await dialog.locator('.perm-dialog-allow').click(); + await expect(page.locator('#log [data-entry="perm:driver:true"]')).toBeVisible(); + }); + test('without a user gesture, a consent dialog collects the click that opens the popup', async ({ page }) => { await page.goto('/'); await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });