mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-11 09:38:35 +00:00
fix: misc auth bug fixes (#3362)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------------------
|
||||
|
||||
+84
-18
@@ -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?.(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user