From eefa53e5bd7f52b2c8338d899cdcb854b119f370 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 8 Jul 2026 20:50:22 -0400 Subject: [PATCH] fix: misc auth bug fixes (#3362) --- src/gui/src/UI/UIWindowSaveAccount.js | 6 +- src/gui/src/UI/UIWindowSignup.js | 32 ++++-- src/gui/src/initgui.js | 52 ++++++++++ src/puter-js/src/index.js | 102 +++++++++++++++---- src/puter-js/src/lib/authTokenOrigin.js | 31 ++++++ src/puter-js/src/lib/authTokenOrigin.test.js | 79 ++++++++++++++ 6 files changed, 271 insertions(+), 31 deletions(-) create mode 100644 src/puter-js/src/lib/authTokenOrigin.js create mode 100644 src/puter-js/src/lib/authTokenOrigin.test.js diff --git a/src/gui/src/UI/UIWindowSaveAccount.js b/src/gui/src/UI/UIWindowSaveAccount.js index 0abdc2b59..bc8ce39d4 100644 --- a/src/gui/src/UI/UIWindowSaveAccount.js +++ b/src/gui/src/UI/UIWindowSaveAccount.js @@ -158,10 +158,12 @@ async function UIWindowSaveAccount (options) { success: async function (data) { window.dispatchEvent(new CustomEvent('account-saved', { detail: { data: data } })); - await window.update_auth_data(data.token, data.user); + // /save_account promotes the current session's user in place + // and does not rotate the token, so keep the one we have. + await window.update_auth_data(data.token ?? window.auth_token, data.user); //close this window - if ( data.user.email_confirmation_required ) { + if ( data.user.requires_email_confirmation && !data.user.email_confirmed ) { let is_verified = await UIWindowEmailConfirmationRequired({ stay_on_top: true, has_head: true, diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index e7e319d91..cd588567b 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -391,13 +391,18 @@ function UIWindowSignup (options) { success: async function (data) { await window.update_auth_data(data.token, data.user); - //send out the login event - if ( options.reload_on_success ) { - window.onbeforeunload = null; - // either options.redirect_url or the current page - const redirectUrl = options.redirect_url || '/'; - window.location.replace(redirectUrl); - } else if ( data.user?.requires_phone_verification || data.user?.requires_card_verification || options.send_confirmation_code || data.user?.requires_email_confirmation ) { + // The phone (SMS) and card gates run before any reload: + // reloading first would tear down the signup flow mid-way + // and leave the dialogs to the boot-time whoami check, + // which not every entry point reliably reaches. The email + // dialog is closable (no retry loop), so in reload flows + // it keeps its old post-reload timing — the boot check + // shows the uncloseable variant — rather than risk a + // dismissed dialog stranding the page before the reload. + let email_verified = true; + const show_email_dialog = !options.reload_on_success && + (options.send_confirmation_code || data.user?.requires_email_confirmation); + if ( data.user?.requires_phone_verification || data.user?.requires_card_verification || show_email_dialog ) { $(el_window).close(); // Low-reputation signups must clear every flagged gate. // Phone (SMS) and email come first; the card gate only @@ -414,8 +419,7 @@ function UIWindowSignup (options) { } while ( !phone_ok ); } - let email_verified = true; - if ( options.send_confirmation_code || data.user?.requires_email_confirmation ) { + if ( show_email_dialog ) { email_verified = await UIWindowEmailConfirmationRequired({ stay_on_top: true, has_head: true, @@ -436,9 +440,15 @@ function UIWindowSignup (options) { } while ( !card_ok ); } - resolve(email_verified); + } + + if ( options.reload_on_success ) { + window.onbeforeunload = null; + // either options.redirect_url or the current page + const redirectUrl = options.redirect_url || '/'; + window.location.replace(redirectUrl); } else { - resolve(true); + resolve(email_verified); } }, error: function (err) { diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 88922279c..286848bca 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -872,6 +872,58 @@ window.initgui = async function (options) { window.location.replace(window.is_dashboard_mode ? '/' : '/desktop'); }); + // ------------------------------------------------------------------------------------- + // Safety net for pending-verification 403s. The backend rejects every + // authenticated call while a signup-time verification (SMS phone, email, + // card) is pending, but the dialogs are normally only opened at boot or + // right after signup. Any session that misses them — temp-user creation, + // save-account, a flag set after this page loaded — lands here, so the + // user gets the right dialog instead of a desktop where every request + // silently fails. + // ------------------------------------------------------------------------------------- + const verification_gate_windows = { + phone_verification_required: UIWindowPhoneVerificationRequired, + email_confirmation_required: UIWindowEmailConfirmationRequired, + card_verification_required: UIWindowCardVerificationRequired, + }; + let verification_gate_open = false; + $(document).ajaxError(async function (event, jqxhr) { + if ( jqxhr?.status !== 403 || verification_gate_open ) { + return; + } + let body = jqxhr.responseJSON; + if ( ! body && jqxhr.responseText ) { + try { body = JSON.parse(jqxhr.responseText); } catch (e) { body = null; } + } + const UIWindowVerificationGate = verification_gate_windows[body?.code]; + if ( ! UIWindowVerificationGate ) { + return; + } + verification_gate_open = true; + try { + // Shown once, not in a retry loop: if the gate is somehow + // dismissed while still pending, the next rejected call lands + // here again and re-opens it. Looping here could stack a second + // dialog on top of the boot-time or signup-flow gate. + const is_verified = await UIWindowVerificationGate({ + show_close_button: false, + stay_on_top: true, + has_head: false, + logout_in_footer: true, + window_options: { + is_draggable: false, + }, + }); + if ( is_verified ) { + await window.refresh_user_data(window.auth_token); + } + } catch (e) { + console.error('verification gate dialog failed:', e); + } finally { + verification_gate_open = false; + } + }); + // ------------------------------------------------------------------------------------- // Authed // ------------------------------------------------------------------------------------- diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 1cae8f343..8697836c4 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -1,5 +1,6 @@ import kvjs from '@heyputer/kv.js'; import APICallLogger from './lib/APICallLogger.js'; +import { isStoredTokenUsableForOrigin } from './lib/authTokenOrigin.js'; import path from './lib/path.js'; import localStorageMemory from './lib/polyfills/localStorage.js'; import xhrshim from './lib/polyfills/xhrshim.js'; @@ -99,6 +100,10 @@ const PROD_ORIGIN = 'https://puter.com'; // to the interactive reauth flow. const STORAGE_KEY_V1 = 'puter.auth.token'; const STORAGE_KEY_V2 = 'puter.auth.token.v2'; +// Records the API origin a stored v2 token was minted against. A stored token +// is only ever replayed to this origin, so a URL-controlled `puter.api_origin` +// can't harvest a previously-stored token and forward it to a foreign origin. +const STORAGE_KEY_ORIGIN_V2 = 'puter.auth.token.origin.v2'; const puterInit = function () { 'use strict'; @@ -504,23 +509,44 @@ const puterInit = function () { this.setAuthToken(bootstrapAuthToken); needsSilentMigration = true; } else { - // Prefer the v2 storage key. Fall back to v1 - // and queue a silent migrate-token call. + // No token in the URL — fall back to a stored token, + // but ONLY if it is allowed for the current API origin. + // In app mode `puter.api_origin` is URL-controlled, so a + // stored token must be bound to (and matched against) the + // origin it was minted for. A custom (non-default) origin + // additionally requires an explicit binding; an unbound + // legacy token is only honored against the default origin. + const boundOrigin = this.normalizeStringCandidate( + localStorage.getItem(STORAGE_KEY_ORIGIN_V2), + ); const v2 = this.normalizeAuthTokenCandidate( localStorage.getItem(STORAGE_KEY_V2), ); - if (v2) { - this.setAuthToken(v2); - selectedAuthToken = v2; - } else { - const v1 = this.normalizeAuthTokenCandidate( - localStorage.getItem(STORAGE_KEY_V1), - ); - if (v1) { - this.setAuthToken(v1); - selectedAuthToken = v1; - needsSilentMigration = true; - } + // v1 (legacy) tokens never carry an origin binding. + const v1 = v2 + ? null + : this.normalizeAuthTokenCandidate( + localStorage.getItem(STORAGE_KEY_V1), + ); + const storedToken = v2 ?? v1; + if ( + storedToken && + this._storedTokenUsableForCurrentOrigin( + v2 ? boundOrigin : null, + ) + ) { + this.setAuthToken(storedToken); + selectedAuthToken = storedToken; + if (v1) needsSilentMigration = true; + } else if (storedToken) { + // A token exists but is not valid for this API + // origin (a URL-supplied custom/attacker origin, or + // an unbound legacy token against a custom origin). + // Treat as unauthenticated and force a reauth for + // this origin instead of replaying the token. + this._needsOriginReauth = { + reason: 'api_origin_mismatch', + }; } } if (needsSilentMigration && selectedAuthToken) { @@ -545,6 +571,18 @@ const puterInit = function () { console.error('Error accessing localStorage:', error); } this.initSubmodules(); + if (this._needsOriginReauth) { + const reauthSignal = this._needsOriginReauth; + this._needsOriginReauth = null; + // The URL-supplied API origin was rejected as untrusted, so + // snap the API origin back to the trusted default before + // reauthing. This guarantees the fresh token is bound to — + // and only ever sent to — the trusted default origin, never + // the URL-supplied one. Reauth itself is pinned to the + // configured GUI origin (see triggerReauth). + this.setAPIOrigin(this.defaultAPIOrigin); + this.triggerReauth(reauthSignal); + } } // SDK was loaded in a 3rd-party website. // When SDK is loaded in GUI the initiation process should start when the DOM is ready. This is because @@ -742,8 +780,15 @@ const puterInit = function () { STORAGE_KEY_V2, normalizedAuthToken, ); + // Persist the origin this token is bound to alongside + // it, so a later boot only reuses it for that origin. + localStorage.setItem( + STORAGE_KEY_ORIGIN_V2, + this.APIOrigin, + ); } else { localStorage.removeItem(STORAGE_KEY_V2); + localStorage.removeItem(STORAGE_KEY_ORIGIN_V2); } // Always clear the legacy v1 key on a write — once we // have a v2 token, the v1 one must not linger or it @@ -771,6 +816,22 @@ const puterInit = function () { }); }; + /** + * Decides whether a stored token may be attached to requests for the + * current API origin. + * - A bound token may only ever be replayed to the exact origin it + * was minted against. + * - An unbound (legacy) token is only honored against the default API + * origin — never against a URL-supplied custom `puter.api_origin`. + */ + _storedTokenUsableForCurrentOrigin = function (boundOrigin) { + return isStoredTokenUsableForOrigin({ + boundOrigin, + currentOrigin: this.APIOrigin, + defaultAPIOrigin: this.defaultAPIOrigin, + }); + }; + setAPIOrigin = function (APIOrigin) { this.APIOrigin = APIOrigin; // reinitialize submodules @@ -810,6 +871,7 @@ const puterInit = function () { if (this.env === 'web' || this.env === 'app') { try { localStorage.removeItem(STORAGE_KEY_V2); + localStorage.removeItem(STORAGE_KEY_ORIGIN_V2); localStorage.removeItem(STORAGE_KEY_V1); } catch (error) { // Handle the error here @@ -852,6 +914,7 @@ const puterInit = function () { if (this.env === 'web' || this.env === 'app') { try { localStorage.removeItem(STORAGE_KEY_V2); + localStorage.removeItem(STORAGE_KEY_ORIGIN_V2); localStorage.removeItem(STORAGE_KEY_V1); } catch (e) { console.error('Error accessing localStorage:', e); @@ -888,10 +951,13 @@ const puterInit = function () { // the existing `puter.token` postMessage delivers the // fresh token back to us. // - // targetOrigin is locked to the expected GUI origin — - // postMessage('*') would leak reauth signal + auth_id - // to any embedding parent (including a malicious one), - // and the message body is only meaningful to the GUI. + // targetOrigin is locked to the configured GUI origin, by + // exact match. We deliberately do NOT trust a URL-supplied + // origin here: postMessage('*') — or an attacker-chosen + // origin — would leak the reauth signal + auth_id to a + // malicious parent and could let it inject a token. The + // configured GUI origin is the only origin allowed to drive + // reauth. try { globalThis.parent?.postMessage?.( { diff --git a/src/puter-js/src/lib/authTokenOrigin.js b/src/puter-js/src/lib/authTokenOrigin.js new file mode 100644 index 000000000..b2e513f99 --- /dev/null +++ b/src/puter-js/src/lib/authTokenOrigin.js @@ -0,0 +1,31 @@ +/** + * Decides whether a stored auth token may be attached to requests destined + * for a given API origin. + * + * In `app` mode the API origin is taken from a URL-controlled + * `puter.api_origin` parameter, so a stored token must never be replayed to + * an arbitrary origin. The rules are: + * + * - A token that was stored with an explicit origin binding may only ever + * be replayed to that exact origin. + * - A token with no binding (a legacy token stored before origin binding + * existed) is only honored against the default API origin — never against + * a URL-supplied custom origin. + * + * @param {object} params + * @param {string|null|undefined} params.boundOrigin - Origin the stored token + * was minted against, or null/undefined for an unbound (legacy) token. + * @param {string} params.currentOrigin - The API origin the token would be + * used against right now. + * @param {string} params.defaultAPIOrigin - The SDK's default (trusted) API + * origin for this deployment. + * @returns {boolean} True if the token may be used for `currentOrigin`. + */ +export const isStoredTokenUsableForOrigin = ({ + boundOrigin, + currentOrigin, + defaultAPIOrigin, +}) => { + if (boundOrigin) return boundOrigin === currentOrigin; + return currentOrigin === defaultAPIOrigin; +}; diff --git a/src/puter-js/src/lib/authTokenOrigin.test.js b/src/puter-js/src/lib/authTokenOrigin.test.js new file mode 100644 index 000000000..0c146f331 --- /dev/null +++ b/src/puter-js/src/lib/authTokenOrigin.test.js @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { isStoredTokenUsableForOrigin } from './authTokenOrigin.js'; + +const DEFAULT = 'https://api.puter.com'; +const ATTACKER = 'https://attacker.example'; + +describe('isStoredTokenUsableForOrigin', () => { + it('allows a bound token only against its own origin', () => { + expect( + isStoredTokenUsableForOrigin({ + boundOrigin: DEFAULT, + currentOrigin: DEFAULT, + defaultAPIOrigin: DEFAULT, + }), + ).toBe(true); + }); + + it('rejects a bound token against a different (URL-supplied) origin', () => { + // The core exploit: a token minted for the real API must not be + // replayed to an attacker-controlled origin. + expect( + isStoredTokenUsableForOrigin({ + boundOrigin: DEFAULT, + currentOrigin: ATTACKER, + defaultAPIOrigin: DEFAULT, + }), + ).toBe(false); + }); + + it('honors an unbound (legacy) token against the default origin', () => { + expect( + isStoredTokenUsableForOrigin({ + boundOrigin: null, + currentOrigin: DEFAULT, + defaultAPIOrigin: DEFAULT, + }), + ).toBe(true); + }); + + it('rejects an unbound (legacy) token against a custom origin', () => { + // Even without a binding, a legacy token can never be sent to a + // custom URL-supplied origin. + expect( + isStoredTokenUsableForOrigin({ + boundOrigin: undefined, + currentOrigin: ATTACKER, + defaultAPIOrigin: DEFAULT, + }), + ).toBe(false); + }); + + it('binds to a self-hosted default origin', () => { + // A self-hosted deployment sets its own default API origin; a token + // bound to it is usable there, and a legacy token is allowed there. + const selfHosted = 'https://api.my-puter.example'; + expect( + isStoredTokenUsableForOrigin({ + boundOrigin: selfHosted, + currentOrigin: selfHosted, + defaultAPIOrigin: selfHosted, + }), + ).toBe(true); + expect( + isStoredTokenUsableForOrigin({ + boundOrigin: null, + currentOrigin: selfHosted, + defaultAPIOrigin: selfHosted, + }), + ).toBe(true); + // ...but that legacy self-hosted token still can't be redirected. + expect( + isStoredTokenUsableForOrigin({ + boundOrigin: null, + currentOrigin: ATTACKER, + defaultAPIOrigin: selfHosted, + }), + ).toBe(false); + }); +});