fix: limit api pointing to apps only (#3470)

This commit is contained in:
Daniel Salazar
2026-07-29 14:03:27 -07:00
committed by GitHub
parent 710e4da4c0
commit 8491a0b55d
11 changed files with 338 additions and 62 deletions
+2 -11
View File
@@ -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 += '<div class="login-progress">';
h += `<div class="profile-pic" style="background-color: #cecece; background-image: url('${profile_pic}'); width: 70px; height: 70px; background-position: center; background-size: cover; border-radius: 50px; margin-bottom: 15px; margin-top: 40px;"></div>`;
h += `<h1 style="text-align: center;
font-size: 17px;
padding: 10px;
font-weight: 300; margin: -10px 10px 4px 10px;">Logging in as <strong>${options.user_info.email === null ? options.user_info.username : options.user_info.email}</strong></h1>`;
// spinner
h += '<svg style="float:left; margin-right: 7px; margin-bottom: 30px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
h += '</div>';
const h = loginProgressBody(options.user_info, profile_pic);
const el_window = await UIWindow({
title: i18n('window_title_authenticating'),
+49
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
const SPINNER_SVG = '<svg style="float:left; margin-right: 7px; margin-bottom: 30px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
/**
* 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 += '<div class="login-progress">';
h += `<div class="profile-pic" style="background-color: #cecece; background-image: url('${html_encode(profilePic ?? '')}'); width: 70px; height: 70px; background-position: center; background-size: cover; border-radius: 50px; margin-bottom: 15px; margin-top: 40px;"></div>`;
h += `<h1 style="text-align: center;
font-size: 17px;
padding: 10px;
font-weight: 300; margin: -10px 10px 4px 10px;">${i18n('logging_in_as', { identity: html_encode(identity) }, false)}</h1>`;
h += SPINNER_SVG;
h += '</div>';
return h;
};
export default loginProgressBody;
+63
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 <strong>${vars.identity}</strong>`;
};
});
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('<strong>alice</strong>');
});
it('encodes an identity carrying markup', () => {
const h = loginProgressBody({ email: '<img src=x onerror=alert(1)>' }, '');
expect(h).not.toContain('<img');
expect(h).toContain('&lt;img src=x onerror=alert(1)&gt;');
});
it('encodes a picture URL that tries to escape the style attribute', () => {
const h = loginProgressBody(
{ username: 'alice' },
"data:image/svg+xml,'); background: red; x: ('",
);
expect(h).not.toContain("'); background: red");
expect(h).toContain('&apos;); background: red');
});
it('renders without a user or a picture', () => {
const h = loginProgressBody(undefined, undefined);
expect(h).toContain('<strong></strong>');
expect(h).toContain("url('')");
});
});
+1 -6
View File
@@ -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 )
{
+1
View File
@@ -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 <strong>{{identity}}</strong>',
looks_good: 'Looks good!',
manage_sessions: 'Manage Sessions',
modified: 'Modified',
+28 -23
View File
@@ -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) {
'<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
);
// 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)
+71
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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;
};
+108
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
+2 -2
View File
@@ -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=<token>&api_origin=<origin>`, then accepts the "Continue as \<username\>?" 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=<token>`, then accepts the "Continue as \<username\>?" 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.
+9 -15
View File
@@ -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=<token>&api_origin=<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=<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 },
);
+4 -5
View File
@@ -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,