From 6d10ef2821b12660a9c0b36723adc42a16748c73 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Fri, 28 Aug 2026 11:16:10 -0700 Subject: [PATCH] fix: stop a URL parameter putting puter.js into app mode (#3660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `env = 'app'` was decided by the presence of a `puter.app_instance_id` query parameter and nothing else, so a crafted link put any page that loads the SDK into app mode — and app mode is what makes the URL's `puter.api_origin` authoritative for every credentialed call. App mode now also requires the document to be framed. The GUI only ever launches an app into an iframe, so this costs a real app nothing while a top-level document carrying the parameters is treated as the third-party site it is. It is not an attestation that the framing document is the GUI — a cross-origin ancestor's identity is not readable — so the token paths carry the rest: - The `web` boot branch adopted a stored token without consulting the origin it was bound to, which is what completed the fixation: one link plants a token bound to an attacker's origin, and every later visit adopted it. It now applies the same binding rule the app branch does, and drops a token that fails it rather than leaving it to be re-read. - `signIn()` had no env guard, and in app mode delivered a real token to whatever `puter.api_origin` the launching URL named. Apps get their token from the session that launched them, so it now rejects there with `not_available_in_app`. Nothing internal reaches it in app mode: `authenticateWithPuter` and both implicit-auth call sites already gate on `env === 'web'`. - The cross-origin-isolated branch polled `${this.APIOrigin}/login/wait` and adopted whatever came back. Pinned to `defaultAPIOrigin`, the same way the popup and its message handler already pin `defaultGUIOrigin`. Backward compatibility: no signature, response field or existing error code changes. The only behaviour a caller can observe is the new `signIn()` rejection, which replaces a call that could not have worked correctly. Covers the SDK side of the parameter PUT-1395 and PUT-1427 closed on the GUI. --- src/docs/src/Auth/signIn.md | 6 ++++- src/puter-js/src/index.js | 29 ++++++++++++++++++++-- src/puter-js/src/lib/appModeGate.js | 28 +++++++++++++++++++++ src/puter-js/src/lib/appModeGate.test.js | 31 ++++++++++++++++++++++++ src/puter-js/src/modules/Auth.js | 21 ++++++++++++++-- 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 src/puter-js/src/lib/appModeGate.js create mode 100644 src/puter-js/src/lib/appModeGate.test.js diff --git a/src/docs/src/Auth/signIn.md b/src/docs/src/Auth/signIn.md index 6731e3430..4c35a4984 100755 --- a/src/docs/src/Auth/signIn.md +++ b/src/docs/src/Auth/signIn.md @@ -1,13 +1,15 @@ --- title: puter.auth.signIn() description: Initiate sign in process in your application with user's Puter account. -platforms: [websites, apps] +platforms: [websites] --- Initiates the sign in process for the user. This will open a popup window with the appropriate authentication method. Puter automatically handles the authentication process and will resolve the promise when the user has signed in. It is important to note that all essential methods in Puter handle authentication automatically. This method is only necessary if you want to handle authentication manually, for example if you want to build your own custom authentication flow. +This is a website-only method. An app running on Puter is already signed in as the user who launched it, so calling it there rejects with `not_available_in_app`. +
The `puter.auth.signIn()` function must be triggered by a user action (such as a click event) because it opens a popup window. Most browsers block popups that are not initiated by user interactions. @@ -43,6 +45,8 @@ The promise will reject with an object containing an `error` code and a human-re - `auth_window_closed`: The user closed the sign-in window (or cancelled the consent dialog) without completing the sign-in process. +- `not_available_in_app`: `signIn()` was called from an app running on Puter. An app is already signed in as the user who launched it — the Puter session hands it a token at launch — so there is nothing for the popup to do. Use `puter.auth.getUser()` to read who that is. + The promise may also reject with the failure response returned by the authentication window itself. ## Example diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 423ebade9..7c296faa1 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -2,6 +2,7 @@ import kvjs from '@heyputer/kv.js'; import APICallLogger from './lib/APICallLogger.js'; import { fetchUrl } from './lib/networkUtils.js'; import { isStoredTokenUsableForOrigin } from './lib/authTokenOrigin.js'; +import { isFramedDocument } from './lib/appModeGate.js'; import path from 'path-browserify'; import localStorageMemory from './lib/polyfills/localStorage.js'; import xhrshim from './lib/polyfills/xhrshim.js'; @@ -448,7 +449,15 @@ export class Puter { let URLParams = new URLSearchParams(globalThis.location?.search); // Figure out the environment in which the SDK is running - if (URLParams.has('puter.app_instance_id')) { + // + // App mode is gated on being framed: the parameter that selects it is + // URL-supplied, and app mode is what makes `puter.api_origin` and + // `puter.auth.token` authoritative. The GUI launches apps into an + // iframe, so a top-level document carrying those is a third-party site. + if ( + URLParams.has('puter.app_instance_id') && + isFramedDocument(globalThis) + ) { this.env = 'app'; } else if (globalThis.puter_gui_enabled === true) { this.env = 'gui'; @@ -676,7 +685,23 @@ export class Puter { const storedToken = this.normalizeAuthTokenCandidate( localStorage.getItem(STORAGE_KEY_V2), ); - if (storedToken) this.setAuthToken(storedToken); + // Same origin binding the app branch applies. A token stored + // during a run whose API origin came from the URL is bound to + // that origin, and replaying one here would boot the page on a + // session someone else planted — so it is dropped rather than + // adopted. In `web` mode the current origin is always the + // default, so a token this page stored itself always passes. + const boundOrigin = this.normalizeStringCandidate( + localStorage.getItem(STORAGE_KEY_ORIGIN_V2), + ); + if ( + storedToken && + this._storedTokenUsableForCurrentOrigin(boundOrigin) + ) { + this.setAuthToken(storedToken); + } else if (storedToken) { + this._clearAuthToken(); + } // if appID is already set in localStorage, then we don't need to show the dialog if (!this.appID && localStorage.getItem('puter.app.id')) { this.setAppID(localStorage.getItem('puter.app.id')); diff --git a/src/puter-js/src/lib/appModeGate.js b/src/puter-js/src/lib/appModeGate.js new file mode 100644 index 000000000..d394aa753 --- /dev/null +++ b/src/puter-js/src/lib/appModeGate.js @@ -0,0 +1,28 @@ +/** + * Whether this document is allowed to run the SDK in `app` mode. + * + * `env = 'app'` is decided by the presence of a `puter.app_instance_id` URL + * parameter, which a crafted link can put on any page — and app mode is what + * makes the URL's `puter.api_origin` and `puter.auth.token` authoritative. The + * GUI only ever launches an app into an iframe, so a top-level document + * presenting those parameters is a third-party site, not an app. + * + * This does not attest that the framing document _is_ the Puter GUI — a + * cross-origin ancestor's identity is not readable. The token-adoption paths + * carry that half, by binding every stored token to the API origin it was + * minted against. + * + * @param {typeof globalThis} [scope] - Global to inspect; injectable for tests. + * @returns {boolean} True when the scope is a framed document. + */ +export const isFramedDocument = (scope = globalThis) => { + try { + const parent = scope?.parent; + // Workers have no `parent`; a top-level document is its own. + return !!parent && parent !== scope; + } catch { + // Reading `parent` can throw only in an embedded context, so an + // exception is itself evidence of framing. + return true; + } +}; diff --git a/src/puter-js/src/lib/appModeGate.test.js b/src/puter-js/src/lib/appModeGate.test.js new file mode 100644 index 000000000..f64a6b11c --- /dev/null +++ b/src/puter-js/src/lib/appModeGate.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { isFramedDocument } from './appModeGate.js'; + +describe('isFramedDocument', () => { + it('rejects a top-level document, which is its own parent', () => { + // The exploit shape: any page can be handed `puter.app_instance_id` + // via a crafted link, and app mode is what makes the URL's + // `puter.api_origin` authoritative. + const scope = {}; + scope.parent = scope; + expect(isFramedDocument(scope)).toBe(false); + }); + + it('accepts a framed document', () => { + expect(isFramedDocument({ parent: {} })).toBe(true); + }); + + it('rejects a scope with no parent at all (worker, node)', () => { + expect(isFramedDocument({})).toBe(false); + expect(isFramedDocument(undefined)).toBe(false); + }); + + it('treats an unreadable parent as framed', () => { + const scope = { + get parent() { + throw new Error('cross-origin'); + }, + }; + expect(isFramedDocument(scope)).toBe(true); + }); +}); diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index 0fe6fa235..fb845d9ae 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -98,7 +98,9 @@ export class AuthModule extends PuterModule { * user's click on it. Resolves once the user has signed in. * * Rejects with `{ error: 'popup_blocked' }` if the browser blocked the - * popup, or `{ error: 'auth_window_closed' }` if the user closed it. + * popup, `{ error: 'auth_window_closed' }` if the user closed it, or + * `{ error: 'not_available_in_app' }` when called from an app — an app's + * token comes from the Puter session that launched it. * * `request_auth` asks the popup to let the user re-pick their account even * when this site already holds a token for them — the GUI otherwise skips @@ -111,6 +113,17 @@ export class AuthModule extends PuterModule { signIn = (options) => { options = options || {}; + // Apps receive their token from the GUI that launched them, not from a + // popup. Running the popup flow under app mode would deliver the token + // to whatever `puter.api_origin` the launching URL named, which in app + // mode is URL-supplied. + if ( puter.env === 'app' ) { + return Promise.reject({ + error: 'not_available_in_app', + msg: 'signIn is not available to an app; the Puter session that launched it provides the token.', + }); + } + return new Promise((resolve, reject) => { const signinsession = crypto.randomUUID(); const msg_id = this.#messageID++; @@ -137,7 +150,11 @@ export class AuthModule extends PuterModule { (async () => { while (true) { try { - const result = await fetchUrl(`${this.APIOrigin}/login/wait`, { + // Pinned to the deployment's own API, the same + // way the popup and its message handler pin + // `defaultGUIOrigin`: this relay hands back a real + // token, so its host must not be one a URL named. + const result = await fetchUrl(`${puter.defaultAPIOrigin}/login/wait`, { method: 'POST', headers: { 'Content-Type': 'application/json',