fix: misc auth bug fixes (#3362)

This commit is contained in:
Daniel Salazar
2026-07-08 17:50:22 -07:00
committed by GitHub
parent db89fbfc52
commit eefa53e5bd
6 changed files with 271 additions and 31 deletions
+84 -18
View File
@@ -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?.(
{
+31
View File
@@ -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);
});
});