diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index d8bd960e0..f67c08e97 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -55,6 +55,7 @@ import { LocaleService } from './services/LocaleService.js'; import { ProcessService } from './services/ProcessService.js'; import { ThemeService } from './services/ThemeService.js'; import { privacy_aware_path } from './util/desktop.js'; +import { deliversTokenToOpener } from './util/popupAuth.js'; const postAuthActions = async (action) => { // ------------------------------------------------------------------------------------- @@ -196,15 +197,7 @@ const postAuthActions = async (action) => { } return; } else { - // A permission prompt is not a sign-in: the user is deciding about - // one permission, so the opener must not walk away holding this - // user's credentials. The exchange below still runs (it bootstraps - // the app row the grant needs and caches `host_app_uid`), but the - // token stays in this window. Handing it over would also let a - // failed exchange sign the opener out, because the SDK feeds the - // `token: null` of the failure message straight into - // setAuthToken(). - const deliver_token_to_opener = action !== 'request-permission'; + const deliver_token_to_opener = deliversTokenToOpener(action); try { let data = await window.getUserAppToken(new URL(window.openerOrigin).origin); // This is an implicit app and the app_uid is sent back from the server @@ -1730,21 +1723,29 @@ window.initgui = async function (options) { // we cache it here so that we can use it later window.host_app_uid = data.app_uid; // send token to parent - window.opener.postMessage( - { - msg: 'puter.token', - success: true, - msg_id: msg_id, - token: data.token, - username: window.user.username, - app_uid: data.app_uid, - }, - window.openerOrigin, - ); + if (deliversTokenToOpener(action)) { + window.opener?.postMessage( + { + msg: 'puter.token', + success: true, + msg_id: msg_id, + token: data.token, + username: window.user.username, + app_uid: data.app_uid, + }, + window.openerOrigin, + ); + } // close popup if (!action || action === 'sign-in') { window.close(); window.open('', '_self').close(); + } else { + // Actions that keep the popup open still + // have work to do after this wait; the + // sleep below only ends it when the + // spinner was up for under 2s. + resolve(); } })(); if (spinner_duration < 2000) { @@ -1787,17 +1788,19 @@ window.initgui = async function (options) { // we cache it here so that we can use it later window.host_app_uid = data.app_uid; // send token to parent - window.opener.postMessage( - { - msg: 'puter.token', - success: true, - msg_id: msg_id, - token: data.token, - username: window.user.username, - app_uid: data.app_uid, - }, - window.openerOrigin, - ); + if (deliversTokenToOpener(action)) { + window.opener?.postMessage( + { + msg: 'puter.token', + success: true, + msg_id: msg_id, + token: data.token, + username: window.user.username, + app_uid: data.app_uid, + }, + window.openerOrigin, + ); + } // close popup if (!action || action === 'sign-in') { window.close(); diff --git a/src/gui/src/util/popupAuth.js b/src/gui/src/util/popupAuth.js new file mode 100644 index 000000000..327bcfbd0 --- /dev/null +++ b/src/gui/src/util/popupAuth.js @@ -0,0 +1,52 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Rules for what a GUI popup may hand back to the site that opened it. + * + * A popup boot (`?embedded_in_popup=true`) mints a user-app token on several + * different paths — the plain token exchange, first-visit temp-user creation, + * and manual signup when temp users are refused — and historically every one of + * them posted `puter.token` to the opener. The SDK's global message listener + * feeds that straight into `setAuthToken()`, so posting it signs the site in. + * + * That is only what the user asked for when the popup's whole purpose was + * signing in. `request-permission` popups must not do it: the user is deciding + * about a single permission, not handing over their account. Posting the + * failure message is just as unsafe, since its `token: null` would clobber a + * token the site already holds. + */ + +/** Popup actions that exist to answer a question, not to authenticate. */ +const NON_AUTH_POPUP_ACTIONS = new Set(['request-permission']); + +/** + * Whether a popup running `action` may post `puter.token` to its opener. + * + * The token exchange itself still runs for the excluded actions — it bootstraps + * the app row a permission grant needs and caches `host_app_uid` — only the + * hand-off to the opener is suppressed. + * + * @param {string|null|undefined} action - The popup's `action`, as parsed from + * the URL (`/action/` or `?action=`); undefined for a plain + * sign-in popup. + * @returns {boolean} `true` if the token may be delivered to the opener. + */ +export const deliversTokenToOpener = (action) => + !NON_AUTH_POPUP_ACTIONS.has(action); diff --git a/src/gui/src/util/popupAuth.test.js b/src/gui/src/util/popupAuth.test.js new file mode 100644 index 000000000..93db460ce --- /dev/null +++ b/src/gui/src/util/popupAuth.test.js @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, it, expect } from 'vitest'; +import { deliversTokenToOpener } from './popupAuth.js'; + +describe('deliversTokenToOpener', () => { + it('withholds the token from a permission prompt', () => { + // Answering a permission prompt is not consent to hand the site this + // user's credentials. Every popup path that mints a user-app token has + // to honour this, not just the plain token exchange. + expect(deliversTokenToOpener('request-permission')).toBe(false); + }); + + it('delivers the token for the sign-in flows that exist to authenticate', () => { + // `undefined` is a plain sign-in popup, which carries no action. + for ( const action of [undefined, 'sign-in'] ) { + expect(deliversTokenToOpener(action)).toBe(true); + } + }); + + it('delivers the token for the other popup actions', () => { + for ( const action of [ + 'show-open-file-picker', + 'show-directory-picker', + 'show-save-file-picker', + 'login', + 'signup', + ] ) { + expect(deliversTokenToOpener(action)).toBe(true); + } + }); +}); diff --git a/src/puter-js/tests/e2e/specs/requestPermission.spec.js b/src/puter-js/tests/e2e/specs/requestPermission.spec.js index 4b46a5db7..93c7a9747 100644 --- a/src/puter-js/tests/e2e/specs/requestPermission.spec.js +++ b/src/puter-js/tests/e2e/specs/requestPermission.spec.js @@ -418,6 +418,36 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => { }); }); +test.describe('puter.ui.requestPermission (env=web popup, first visit)', () => { + // No Puter session at all: the popup takes the first-visit branch, which + // creates a temp user. That path mints its own user-app token, so it has to + // withhold it from the opener for the same reason the plain token exchange + // does — otherwise a site that only ever asked about one permission walks + // away signed in as a brand-new account. + test.use({ storageState: { cookies: [], origins: [] } }); + + test('creating a user to answer the prompt does not sign the site in', async ({ page }) => { + await page.goto(PERMISSION_FIXTURE_URL); + await page.locator('body.ready').waitFor({ timeout: 60_000 }); + expect(await page.evaluate(() => !!puter.authToken)).toBe(false); + + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('#req-driver-perm').click(), + ]); + + // The prompt still has to arrive: the first-visit path waits on the + // spinner before handing back control, and that wait has to end for + // any action that keeps the popup open. + await expect(popup.locator('dialog.perm-dialog')).toBeVisible({ timeout: 60_000 }); + await popup.locator('dialog.perm-dialog .perm-dialog-deny').click(); + await expect(page.locator('#log [data-entry="perm:driver:false"]')).toBeVisible(); + + expect(await page.evaluate(() => !!puter.authToken)).toBe(false); + expect(await page.evaluate(() => localStorage.getItem('puter.auth.token.v2'))).toBeNull(); + }); +}); + test.describe('request-permission action hardening', () => { test('an app_uid in the URL never produces a prompt on its own', async ({ page }) => { // The uid identifies who receives the grant, so it must come from the