diff --git a/src/gui/src/UI/UIWindowLoginInProgress.js b/src/gui/src/UI/UIWindowLoginInProgress.js index 0ece76689..7b3b98307 100644 --- a/src/gui/src/UI/UIWindowLoginInProgress.js +++ b/src/gui/src/UI/UIWindowLoginInProgress.js @@ -18,6 +18,7 @@ */ import UIWindow from './UIWindow.js'; +import { loginProgressBody } from './loginProgressBody.js'; async function UIWindowLoginInProgress (options) { return new Promise(async (resolve) => { @@ -34,17 +35,7 @@ async function UIWindowLoginInProgress (options) { profile_pic = window.icons['profile.svg']; } - let h = ''; - h += '
'; - h += `
`; - h += `

Logging in as ${options.user_info.email === null ? options.user_info.username : options.user_info.email}

`; - // spinner - h += 'circle anim'; - - h += '
'; + const h = loginProgressBody(options.user_info, profile_pic); const el_window = await UIWindow({ title: i18n('window_title_authenticating'), diff --git a/src/gui/src/UI/loginProgressBody.js b/src/gui/src/UI/loginProgressBody.js new file mode 100644 index 000000000..ca900639c --- /dev/null +++ b/src/gui/src/UI/loginProgressBody.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 . + */ + +const SPINNER_SVG = 'circle anim'; + +/** + * Body markup for the "logging in as …" window. + * + * Every interpolated value is encoded: the user object is whatever the API + * answered `whoami` with, and the picture lands inside a quoted `style` + * attribute where an unencoded quote would end the attribute. + * + * @param {{ username?: string, email?: string|null }} [userInfo] + * @param {string} [profilePic] - Image URL or data URI. + * @returns {string} + */ +export const loginProgressBody = (userInfo, profilePic) => { + const identity = userInfo?.email || userInfo?.username || ''; + + let h = ''; + h += '
'; + h += `
`; + h += `

${i18n('logging_in_as', { identity: html_encode(identity) }, false)}

`; + h += SPINNER_SVG; + h += '
'; + + return h; +}; + +export default loginProgressBody; diff --git a/src/gui/src/UI/loginProgressBody.test.js b/src/gui/src/UI/loginProgressBody.test.js new file mode 100644 index 000000000..aa76d70e7 --- /dev/null +++ b/src/gui/src/UI/loginProgressBody.test.js @@ -0,0 +1,63 @@ +/* + * 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, beforeAll } from 'vitest'; +import { encode } from 'html-entities'; +import { loginProgressBody } from './loginProgressBody.js'; + +beforeAll(() => { + // Same encoder the GUI installs globally (see lib/html-entities.js). + globalThis.html_encode = str => encode(str); + // Placeholder substitution only — the caller is responsible for encoding, + // which is exactly what these tests check. + globalThis.i18n = (key, vars = {}, encode_html = true) => { + expect(encode_html).toBe(false); + return `Logging in as ${vars.identity}`; + }; +}); + +describe('loginProgressBody', () => { + it('shows the email, falling back to the username', () => { + expect(loginProgressBody({ username: 'alice', email: 'alice@example.com' }, '')) + .toContain('alice@example.com'); + expect(loginProgressBody({ username: 'alice', email: null }, '')) + .toContain('alice'); + }); + + it('encodes an identity carrying markup', () => { + const h = loginProgressBody({ email: '' }, ''); + expect(h).not.toContain(' { + const h = loginProgressBody( + { username: 'alice' }, + "data:image/svg+xml,'); background: red; x: ('", + ); + expect(h).not.toContain("'); background: red"); + expect(h).toContain(''); background: red'); + }); + + it('renders without a user or a picture', () => { + const h = loginProgressBody(undefined, undefined); + expect(h).toContain(''); + expect(h).toContain("url('')"); + }); +}); diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 9fda10e21..f5694ca4b 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -578,7 +578,7 @@ window.refresh_user_data = async (auth_token) => { } }; -window.update_auth_data = async (auth_token, user, api_origin) => { +window.update_auth_data = async (auth_token, user) => { window.auth_token = auth_token; // Write the v2 key going forward and clear any lingering v1 key so // a single localStorage source-of-truth is used. @@ -606,11 +606,6 @@ window.update_auth_data = async (auth_token, user, api_origin) => { } } - if ( api_origin ) { - window.api_origin = api_origin; - localStorage.setItem('api_origin', api_origin); - } - // Has username changed? if ( window.user?.username !== user.username ) { diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 088cf2196..8192c863c 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -181,6 +181,7 @@ const en = { log_in: 'Log In', log_into_another_account_anyway: 'Log into another account anyway', log_out: 'Log Out', + logging_in_as: 'Logging in as {{identity}}', looks_good: 'Looks good!', manage_sessions: 'Manage Sessions', modified: 'Modified', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 23257c45e..eeafcccb9 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -58,6 +58,7 @@ import { ThemeService } from './services/ThemeService.js'; // factory name so a bare `privacy_aware_path(path)` call in this module can't // silently resolve to the factory — use `window.privacy_aware_path` instead. import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js'; +import { resolveAPIOrigin } from './util/apiOrigin.js'; import { deliversTokenToOpener, trustsOpenerOriginParam, @@ -896,6 +897,9 @@ window.initgui = async function (options) { .filter((element) => element); window.url_paths = url_paths; + // GET query params provided + window.url_query_params = new URLSearchParams(window.location.search); + // Install device signal helpers; collection is lazy. The fingerprint is // on by default (gui_params.thumbmarkEnabled = false kills it); the Prelude // dispatch id needs gui_params.preludeSdkKey. @@ -907,11 +911,25 @@ window.initgui = async function (options) { if (window.auth_token && puter.authToken !== window.auth_token) { puter.setAuthToken(window.auth_token); } - // update SDK if api_origin is different from the one in the SDK - if (window.api_origin && puter.APIOrigin !== window.api_origin) { - puter.setAPIOrigin( - localStorage.getItem('api_origin') || window.api_origin, - ); + // Point the SDK at this deployment's own API. Anywhere but a Puter served + // off the developer's own machine, that is the only origin we'll use — see + // resolveAPIOrigin for why, and for the dev flow that is the exception. + if (window.api_origin) { + const api_origin = resolveAPIOrigin({ + configuredOrigin: window.api_origin, + guiOrigin: window.location.origin, + urlOrigin: window.url_query_params.get('api_origin'), + storedOrigin: localStorage.getItem('api_origin'), + }); + if (api_origin === window.api_origin) { + localStorage.removeItem('api_origin'); + } else { + // Sticks for the rest of the session: only the boot that carried + // the parameter has it in the URL. + localStorage.setItem('api_origin', api_origin); + window.api_origin = api_origin; + } + if (puter.APIOrigin !== api_origin) puter.setAPIOrigin(api_origin); } // Print the version to the console @@ -960,9 +978,6 @@ window.initgui = async function (options) { '', ); - // GET query params provided - window.url_query_params = new URLSearchParams(window.location.search); - // will hold the result of the whoami API call let whoami; @@ -1333,17 +1348,12 @@ window.initgui = async function (options) { // ------------------------------------------------------------------------------------- else if (window.url_query_params.has('auth_token')) { let query_param_auth_token = window.url_query_params.get('auth_token'); - let api_origin; - - // check if we have api_origin in the URL query params - if (window.url_query_params.has('api_origin')) { - api_origin = window.url_query_params.get('api_origin'); - puter.setAPIOrigin(api_origin); - } const previous_auth_token = window.auth_token; - const previous_api_origin = window.api_origin; + // The token is resolved against our own API origin — deliberately not + // one named by the same URL that carried the token, which would leave + // the identity we're about to render up to whoever wrote the link. puter.setAuthToken(query_param_auth_token); try { @@ -1358,7 +1368,7 @@ window.initgui = async function (options) { // Confirm the identity before adopting a token that came from the // URL. Every other path into `update_auth_data` is an account the // user picked in the UI, so those don't ask. - if (whoami && window.user?.uuid !== whoami.uuid) { + if (whoami && (!window.user || window.user.uuid !== whoami.uuid)) { const proceed = await UIAlert({ type: 'confirm', // `false` — UIAlert encodes the message itself. @@ -1376,7 +1386,6 @@ window.initgui = async function (options) { // Back to whatever session was already here; normal boot // picks up below. puter.setAuthToken(previous_auth_token); - if (api_origin) puter.setAPIOrigin(previous_api_origin); whoami = null; } } @@ -1432,11 +1441,7 @@ window.initgui = async function (options) { // show login progress window UIWindowLoginInProgress({ user_info: whoami }); // update auth data - await window.update_auth_data( - query_param_auth_token, - whoami, - api_origin, - ); + await window.update_auth_data(query_param_auth_token, whoami); } // remove auth_token from URL, keeping the current path (e.g. `/` or `/desktop`) // and hash (dashboard tab links like /#usage) diff --git a/src/gui/src/util/apiOrigin.js b/src/gui/src/util/apiOrigin.js new file mode 100644 index 000000000..5e1fe52fe --- /dev/null +++ b/src/gui/src/util/apiOrigin.js @@ -0,0 +1,71 @@ +/** + * 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 . + */ + +const LOOPBACK_HOSTNAMES = new Set(['localhost', '[::1]', '::1']); +const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + +/** + * Is this origin a Puter served off the developer's own machine? + * + * @param {string} [origin] - An absolute origin, e.g. `https://puter.com`. + * @returns {boolean} + */ +export const isLoopbackOrigin = (origin) => { + let hostname; + try { + hostname = new URL(origin).hostname; + } catch (e) { + return false; + } + + return LOOPBACK_HOSTNAMES.has(hostname) || + hostname.endsWith('.localhost') || + LOOPBACK_IPV4.test(hostname); +}; + +/** + * The API origin the GUI talks to. + * + * Normally this deployment's own configured origin, and nothing else: an + * origin named by the URL would let a link resolve the signed-in desktop -- + * `whoami` included -- against a server we don't control. Apps may repoint + * their own SDK instance; the GUI may not. + * + * The one exception is a Puter served from the developer's own machine, where + * `npm start -- --server` drives a local GUI against a remote backend. That + * choice sticks for the rest of the session, so the caller persists whatever + * comes back when it differs from the configured origin, and passes it here as + * `storedOrigin` on later boots. + * + * @param {object} params + * @param {string} params.configuredOrigin - Server-templated `api_origin`. + * @param {string} params.guiOrigin - Origin this page is served from. + * @param {string|null} [params.urlOrigin] - `?api_origin=` from the URL. + * @param {string|null} [params.storedOrigin] - Origin kept from a past boot. + * @returns {string} + */ +export const resolveAPIOrigin = ({ + configuredOrigin, + guiOrigin, + urlOrigin, + storedOrigin, +}) => { + if (!isLoopbackOrigin(guiOrigin)) return configuredOrigin; + return urlOrigin || storedOrigin || configuredOrigin; +}; diff --git a/src/gui/src/util/apiOrigin.test.js b/src/gui/src/util/apiOrigin.test.js new file mode 100644 index 000000000..950d40290 --- /dev/null +++ b/src/gui/src/util/apiOrigin.test.js @@ -0,0 +1,108 @@ +/* + * 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 { isLoopbackOrigin, resolveAPIOrigin } from './apiOrigin.js'; + +const CONFIGURED = 'https://api.puter.com'; +const EVIL = 'https://attacker.example'; + +describe('isLoopbackOrigin', () => { + it('accepts the hosts a local Puter is served from', () => { + for ( const origin of [ + 'http://localhost:4100', + 'http://puter.localhost:4100', + 'http://api.puter.localhost:4100', + 'http://127.0.0.1:4100', + 'http://127.10.0.2', + 'http://[::1]:4100', + ] ) { + expect(isLoopbackOrigin(origin), origin).toBe(true); + } + }); + + it('rejects everything else, including lookalikes', () => { + for ( const origin of [ + 'https://puter.com', + 'https://myputer.example', + 'https://localhost.attacker.example', + 'https://notlocalhost', + 'https://127.0.0.1.attacker.example', + 'not a url', + undefined, + ] ) { + expect(isLoopbackOrigin(origin), String(origin)).toBe(false); + } + }); +}); + +describe('resolveAPIOrigin', () => { + it('ignores a URL-supplied origin on a hosted deployment', () => { + expect(resolveAPIOrigin({ + configuredOrigin: CONFIGURED, + guiOrigin: 'https://puter.com', + urlOrigin: EVIL, + })).toBe(CONFIGURED); + }); + + it('ignores a stored origin on a hosted deployment', () => { + expect(resolveAPIOrigin({ + configuredOrigin: CONFIGURED, + guiOrigin: 'https://puter.com', + storedOrigin: EVIL, + })).toBe(CONFIGURED); + }); + + it('ignores both on a self-hosted deployment', () => { + expect(resolveAPIOrigin({ + configuredOrigin: 'https://api.myputer.example', + guiOrigin: 'https://myputer.example', + urlOrigin: EVIL, + storedOrigin: EVIL, + })).toBe('https://api.myputer.example'); + }); + + it('honors a URL-supplied origin on a local Puter', () => { + expect(resolveAPIOrigin({ + configuredOrigin: 'http://api.puter.localhost:4100', + guiOrigin: 'http://puter.localhost:4100', + urlOrigin: CONFIGURED, + })).toBe(CONFIGURED); + }); + + it('falls back to a stored origin on a local Puter, URL winning', () => { + const local = { + configuredOrigin: 'http://api.puter.localhost:4100', + guiOrigin: 'http://puter.localhost:4100', + }; + expect(resolveAPIOrigin({ ...local, storedOrigin: CONFIGURED })) + .toBe(CONFIGURED); + expect(resolveAPIOrigin({ ...local, urlOrigin: CONFIGURED, storedOrigin: EVIL })) + .toBe(CONFIGURED); + }); + + it('falls back to the configured origin when nothing is supplied', () => { + expect(resolveAPIOrigin({ + configuredOrigin: CONFIGURED, + guiOrigin: 'http://puter.localhost:4100', + urlOrigin: null, + storedOrigin: null, + })).toBe(CONFIGURED); + }); +}); diff --git a/src/puter-js/TESTING.md b/src/puter-js/TESTING.md index cb30b152f..d2406ddef 100644 --- a/src/puter-js/TESTING.md +++ b/src/puter-js/TESTING.md @@ -120,8 +120,8 @@ By default, video and trace are saved **only on failure**. `test:e2e:record` ena `globalSetup` runs at the start of the test run: 1. Direct `POST /login` from Node with the admin credentials → JWT token. -2. Probes `window.api_origin` from Puter (server-templated) so we can pass it through. -3. Opens chromium once and navigates to `puter.localhost:4100/?auth_token=&api_origin=`, then accepts the "Continue as \?" confirmation the auth_token handler shows before adopting a token from a URL. Puter's `initgui` auth_token handler then runs the full auth setup: `puter.setAuthToken`, `puter.setAPIOrigin`, `/session/sync-cookie`, `update_auth_data`. +2. Probes `window.api_origin` from Puter (server-templated) so the SDK's adopted origin can be asserted — the GUI always uses its own API origin and will not take one from the URL. +3. Opens chromium once and navigates to `puter.localhost:4100/?auth_token=`, then accepts the "Continue as \?" confirmation the auth_token handler shows before adopting a token from a URL. Puter's `initgui` auth_token handler then runs the full auth setup: `puter.setAuthToken`, `/session/sync-cookie`, `update_auth_data`. 4. Saves cookies + localStorage to `tests/e2e/.auth/state.json` (gitignored, cached for 24h). Every test context loads with that `storageState` → already signed in as admin → no per-test `/login`, no `/signup`, no rate limits. diff --git a/src/puter-js/tests/e2e/globalSetup.js b/src/puter-js/tests/e2e/globalSetup.js index 549d24234..3dbd9e204 100644 --- a/src/puter-js/tests/e2e/globalSetup.js +++ b/src/puter-js/tests/e2e/globalSetup.js @@ -90,17 +90,15 @@ export default async function globalSetup () { // 1) Direct API login → token (no browser, no /signup, no rate-limit risk). const token = await loginAsAdmin({ origin: PUTER_ORIGIN, username: ADMIN_USERNAME, password: ADMIN_PASSWORD }); - // 2) Open Puter with ?auth_token=&api_origin= so initgui's - // auth_token flow runs the full setup (puter.setAuthToken, - // puter.setAPIOrigin, /session/sync-cookie, update_auth_data) and persists - // the API origin to localStorage.api_origin — otherwise the bundled SDK - // keeps its production default (https://api.puter.com) and apps.create - // hits the wrong server. + // 2) Open Puter with ?auth_token= so initgui's auth_token flow runs + // the full setup (puter.setAuthToken, /session/sync-cookie, + // update_auth_data). The GUI points the SDK at its own templated + // api_origin — that is not overridable from the URL. const browser = await chromium.launch(); const context = await browser.newContext(); const page = await context.newPage(); - // Probe the GUI's templated api_origin first so we can pass it through. + // Probe the GUI's templated api_origin so we can assert the SDK adopts it. await page.goto(PUTER_ORIGIN); const apiOrigin = await page.evaluate(() => window.api_origin || null); if ( ! apiOrigin ) { @@ -108,10 +106,7 @@ export default async function globalSetup () { throw new Error(`Could not determine api_origin from ${PUTER_ORIGIN} (window.api_origin was empty).`); } - await page.goto( - `${PUTER_ORIGIN}/?auth_token=${encodeURIComponent(token)}` + - `&api_origin=${encodeURIComponent(apiOrigin)}`, - ); + await page.goto(`${PUTER_ORIGIN}/?auth_token=${encodeURIComponent(token)}`); await page.waitForFunction(() => !!window.puter, null, { timeout: 60_000 }); @@ -122,13 +117,12 @@ export default async function globalSetup () { .click({ timeout: 60_000 }); await page.waitForFunction( - () => { + expectedApiOrigin => { // v2 key since the v1→v2 cutover; legacy key tolerated. const ls = (typeof localStorage !== 'undefined') ? (localStorage.getItem('auth_token_v2') || localStorage.getItem('auth_token')) : null; - const lsApi = (typeof localStorage !== 'undefined') ? localStorage.getItem('api_origin') : null; - return !!(ls && lsApi && window.auth_token && window.puter?.authToken && window.puter?.APIOrigin === lsApi); + return !!(ls && window.auth_token && window.puter?.authToken && window.puter?.APIOrigin === expectedApiOrigin); }, - null, + apiOrigin, { timeout: 60_000 }, ); diff --git a/src/puter-js/tests/e2e/helpers/testApp.js b/src/puter-js/tests/e2e/helpers/testApp.js index 0678950bd..d5ac0e6cc 100644 --- a/src/puter-js/tests/e2e/helpers/testApp.js +++ b/src/puter-js/tests/e2e/helpers/testApp.js @@ -15,8 +15,8 @@ const PUTER_READY_TIMEOUT = 60_000; export async function waitForPuterReady (page) { await page.waitForFunction(() => !!window.puter, null, { timeout: PUTER_READY_TIMEOUT }); - // With storageState, sign-in should already be done. Wait for both auth - // and the API origin to be picked up by the SDK from localStorage. + // With storageState, sign-in should already be done. Wait for the token to + // be picked up and the SDK pointed at the GUI's own API origin. try { await page.waitForFunction( () => { @@ -25,11 +25,10 @@ export async function waitForPuterReady (page) { const ls = (typeof localStorage !== 'undefined') ? (localStorage.getItem('auth_token_v2') || localStorage.getItem('auth_token')) : null; - const lsApi = (typeof localStorage !== 'undefined') ? localStorage.getItem('api_origin') : null; return !!( - ls && lsApi && + ls && window.auth_token && window.puter?.authToken && - window.puter?.APIOrigin === lsApi + window.puter?.APIOrigin === window.api_origin ); }, null,