Match the popup's messages against a canonical GUI origin

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.
This commit is contained in:
jelveh
2026-07-26 15:19:46 -07:00
parent 6af426b132
commit 54f5989e4e
2 changed files with 47 additions and 2 deletions
+18 -2
View File
@@ -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
@@ -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 });