fix: PUT-1355 PUT-1351 PUT-1208 (#3420)

* fix: PUT-1355 PUT-1351 PUT-1208

* fix: dev center header
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-07-21 14:17:33 -07:00
1 parent 645409eddb
commit 89f9f9728f
13 files changed
+414 -44

No files matched your search

@@ -277,6 +277,43 @@ describe('HomepageController GET /app/:name', () => {
expect(html).toMatch(/<!DOCTYPE html>/i);
expect(html).toContain('Cool App');
});
it('does not leak a private app index_url or owner id to an anonymous visitor', async () => {
const { userId } = await makeUser();
const name = `priv-${Math.random().toString(36).slice(2, 10)}`;
const secretUrl = `https://secret-${name}.example.com/`;
// `is_private` is a READ_ONLY_COLUMN, so it can't be set through the
// store's create/update allow-list — insert the private row directly.
await server.clients.db.write(
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`description\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
`app-${uuidv4()}`,
name,
'Private App',
'a private app row',
secretUrl,
userId,
1,
],
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/app/:name',
makeReq({ params: { name }, path: `/app/${name}` }),
res,
);
expect(captured.statusCode).toBe(200);
const html = String(captured.body);
// Public meta (title) is fine to render...
expect(html).toContain('Private App');
// ...but the private hosting URL and owner id must be redacted.
expect(html).not.toContain(secretUrl);
expect(html).not.toContain('owner_user_id');
});
});
// ── /show/* ─────────────────────────────────────────────────────────
@@ -101,6 +101,18 @@ export class HomepageController extends PuterController {
? safeJsonParse(app.metadata)
: (app.metadata as Record<string, unknown> | null)) ??
{};
// Never bake the raw store row into the shell — it exposes
// `index_url`, `owner_user_id`, and admin flags. Run it
// through the authoritative serializer, which redacts to a
// safe field subset and drops `index_url` for a private app
// this actor (possibly anonymous) isn't entitled to.
const actor =
(req as express.Request & { actor?: unknown }).actor ??
null;
const clientApp = await this.drivers.apps.toClientView(
app,
actor,
);
await sendShell(req, res, {
title: String(app.title ?? name),
description: String(app.description ?? ''),
@@ -110,7 +122,7 @@ export class HomepageController extends PuterController {
typeof metadata.social_image === 'string'
? metadata.social_image
: undefined,
app: app as Record<string, unknown>,
app: clientApp as Record<string, unknown>,
});
return;
}
@@ -331,6 +331,27 @@ describe('buildPrivateHostRedirect', () => {
expect(url).toBe('http://beans.app.puter.localhost:4100/');
});
it('collapses a scheme-relative path so it cannot escape the host (open redirect)', () => {
for (const evil of [
'//evil.com/',
'///evil.com/',
'/\\evil.com/',
'/\\/evil.com/',
]) {
const url = buildPrivateHostRedirect(
reqOf({
hostname: 'beans.site.puter.localhost',
originalUrl: evil,
}),
{ name: 'beans' },
cfg,
);
expect(url).toBe(
'http://beans.app.puter.localhost:4100/evil.com/',
);
}
});
it('returns null when no private hosting domain is configured', () => {
const noPrivate = {
...cfg,
@@ -400,6 +421,26 @@ describe('buildPublicHostRedirect', () => {
expect(url).toBe('http://beans.site.puter.localhost:4100/');
});
it('collapses a scheme-relative path so it cannot escape the host (open redirect)', () => {
for (const evil of [
'//evil.com/',
'///evil.com/',
'/\\evil.com/',
'/\\/evil.com/',
]) {
const url = buildPublicHostRedirect(
reqOf({
hostname: 'beans.app.puter.localhost',
originalUrl: evil,
}),
cfg,
);
expect(url).toBe(
'http://beans.site.puter.localhost:4100/evil.com/',
);
}
});
it('returns null when no public hosting domain is configured', () => {
const noPublic = {
...cfg,
@@ -584,6 +584,17 @@ export async function resolvePublicHostedIdentity(opts: {
// -- Redirect helpers ------------------------------------------------
/**
* Turn a request path into a safe reference for `new URL(path, base)`.
* Collapses any run of leading slashes/backslashes into a single `/` so a
* scheme-relative path (`//evil.com`, `/\evil.com`, which the URL parser
* folds to `//`) can't override the base authority and turn the redirect
* into an open redirect. Guarantees exactly one leading slash.
*/
function normalizeRedirectPath(originalUrl: string | undefined): string {
return '/' + (originalUrl || '/').replace(/^[/\\]+/, '');
}
/** Build the URL to redirect a private-app request to its private host. */
export function buildPrivateHostRedirect(
req: Request,
@@ -605,9 +616,7 @@ export function buildPrivateHostRedirect(
try {
const protocol = config.protocol || req.protocol || 'https';
const base = `${protocol}://${subdomain}.${privateDomain}`;
const reqPath = (req.originalUrl || '/').startsWith('/')
? req.originalUrl || '/'
: `/${req.originalUrl}`;
const reqPath = normalizeRedirectPath(req.originalUrl);
return new URL(reqPath, base).toString();
} catch {
return null;
@@ -638,9 +647,7 @@ export function buildPublicHostRedirect(
try {
const protocol = config.protocol || req.protocol || 'https';
const base = `${protocol}://${subdomain}.${publicDomain}`;
const reqPath = (req.originalUrl || '/').startsWith('/')
? req.originalUrl || '/'
: `/${req.originalUrl}`;
const reqPath = normalizeRedirectPath(req.originalUrl);
return new URL(reqPath, base).toString();
} catch {
return null;
+17
View File
@@ -890,6 +890,23 @@ export class AppDriver extends PuterDriver {
return subdomainOwnerId !== appOwnerId;
}
/**
* Public wrapper over the authoritative client serializer. Callers
* outside the driver (e.g. the homepage shell baking app metadata into
* the rendered GUI) must never embed a raw store row — it carries
* `owner_user_id`, admin flags, and the private `index_url`. This
* projects the safe field subset and applies the private-app
* `index_url` gate for `actor`, which may be null for an anonymous
* request.
*
* @param {Record<string, unknown>} app - raw app store row
* @param {any} [actor] - request actor, or null when anonymous
* @param {Record<string, unknown>} [params]
*/
async toClientView(app, actor = null, params = {}) {
return this.#toClient(app, actor, params);
}
async #toClient(app, actor, params = {}) {
if (!app) return null;
@@ -476,6 +476,17 @@ describe('MeteringService', () => {
expect(result['kv:write']).toMatchObject({ count: 1, units: 2 });
});
it('returns zero and writes nothing when every item is skipped', async () => {
const incrSpy = vi.spyOn(server.stores.kv, 'incr');
const result = await target.batchIncrementUsages(actor, [
{ usageType: '', usageAmount: 1, costOverride: 10 },
{ usageType: 'kv:write', usageAmount: 0, costOverride: 20 },
]);
expect(result).toEqual({ total: 0 });
expect(incrSpy).not.toHaveBeenCalled();
incrSpy.mockRestore();
});
it('raises an alarm for any negative costOverride in the batch', async () => {
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
await target.batchIncrementUsages(actor, [
@@ -351,6 +351,11 @@ export class MeteringService extends PuterService {
(aggregated[`${escaped}.count`] || 0) + 1;
}
// Every usage entry may be skipped (zero amount or missing type);
// an empty map would build an invalid `SET ` update expression.
if (Object.keys(aggregated).length === 0)
return { total: 0 } as UsageByType;
const appId = actor.app?.uid || GLOBAL_APP_KEY;
const userId = actor.user.uuid!;
@@ -556,6 +556,12 @@ describe('SystemKVStore', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects when pathAndAmountMap is empty', async () => {
await expect(
target.incr({ key: 'k', pathAndAmountMap: {} }, opts),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects when any value in pathAndAmountMap is not a number', async () => {
await expect(
target.incr(
+8 -16
View File
@@ -334,8 +334,7 @@ export class SystemKVStore extends PuterStore {
res: true,
usage: writeUsage(
(response.ConsumedCapacity?.CapacityUnits as
| number
| undefined) ?? 1,
number | undefined) ?? 1,
),
};
}
@@ -366,9 +365,7 @@ export class SystemKVStore extends PuterStore {
| { key: string; value: unknown }[]
| {
items:
| string[]
| unknown[]
| { key: string; value: unknown }[];
string[] | unknown[] | { key: string; value: unknown }[];
cursor?: string;
total?: number;
}
@@ -447,8 +444,7 @@ export class SystemKVStore extends PuterStore {
usage,
readUsage(
(response.ConsumedCapacity?.CapacityUnits as
| number
| undefined) ?? 1,
number | undefined) ?? 1,
),
);
return response;
@@ -465,8 +461,7 @@ export class SystemKVStore extends PuterStore {
const skip = await runQuery(remaining, startKey, 'COUNT');
remaining -= Number(skip.Count ?? 0);
startKey = skip.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
if (!startKey) {
exhausted = remaining > 0;
break;
@@ -489,8 +484,7 @@ export class SystemKVStore extends PuterStore {
>),
);
nextKey = response.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
pages++;
if (normalizedLimit === undefined) {
// Legacy full listing: follow continuation pages so the
@@ -526,8 +520,7 @@ export class SystemKVStore extends PuterStore {
const counted = await runQuery(0, countKey, 'COUNT');
total += Number(counted.Count ?? 0);
countKey = counted.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
} while (countKey);
}
@@ -613,7 +606,7 @@ export class SystemKVStore extends PuterStore {
KVResult<T extends { '': number } ? number : RecursiveRecord<number>>
> {
assertKey(key);
if (!pathAndAmountMap)
if (!pathAndAmountMap || Object.keys(pathAndAmountMap).length === 0)
throw new HttpError(400, 'kv: incr requires pathAndAmountMap', {
legacyCode: 'bad_request',
});
@@ -846,8 +839,7 @@ export class SystemKVStore extends PuterStore {
res: response.Attributes?.value,
usage: writeUsage(
(response.ConsumedCapacity?.CapacityUnits as
| number
| undefined) ?? 1,
number | undefined) ?? 1,
),
};
} catch (e) {
+14 -15
View File
@@ -280,25 +280,24 @@
<!---------------------------------------->
<dialog class="app-users-modal" style="width: 500px; max-width: 90vw;">
<p>Users who have authenticated into <strong class="app-users-modal-app-name"></strong></p>
<div class="app-users-list-container" style="max-height: 400px; overflow-y: auto; margin-top: 10px;">
<table class="table" id="app-users-table" style="width: 100%;">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
</tr>
</thead>
<tbody></tbody>
</table>
<p class="app-users-empty-notice" style="display:none; text-align:center; color:#888;">No users yet.</p>
</div>
<div class="app-users-list-container" style="max-height: 400px; overflow-y: auto; margin-top: 10px;">
<table class="table" id="app-users-table" style="width: 100%;">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
</tr>
</thead>
<tbody></tbody>
</table>
<p class="app-users-empty-notice" style="display:none; text-align:center; color:#888;">No users yet.</p>
</div>
<div style="margin-top: 15px; display: flex; justify-content: space-between; align-items: center;">
<div style="margin-top: 15px; display: flex; justify-content: space-between; align-items: center;">
<span class="app-users-loading-notice" style="display:none; color:#888; font-size: 13px;">Loading…</span>
<button class="button app-users-load-more-btn" style="display:none;">Load more</button>
<span class="app-users-cancel" style="cursor:pointer; margin-left:auto;">Close</span>
</div>
</dialog>
</div>
</dialog>
<!---------------------------------------->
<!-- App List -->
+28 -6
View File
@@ -19,6 +19,7 @@
import update_username_in_gui from '../helpers/update_username_in_gui.js';
import { openRevalidatePopup } from '../util/openid.js';
import { fetchWithSessionCookieRetry, isSessionAuthError } from '../util/sessionAuth.js';
import UIWindow from './UIWindow.js';
async function UIWindowChangeUsername (options) {
@@ -126,8 +127,9 @@ async function UIWindowChangeUsername (options) {
await myOpenRevalidatePopup();
const res = await doSubmit();
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
const data = await res.json().catch(() => ({}));
if ( res.ok ) onSuccess();
else if ( isSessionAuthError(res, data) ) onReauthRequired(data);
else onError(data.message || 'Request failed');
return;
}
@@ -136,17 +138,26 @@ async function UIWindowChangeUsername (options) {
$(el_window).find('.new-username, .change-username-password').attr('disabled', true);
let res = await doSubmit(password);
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
const data = await res.json().catch(() => ({}));
if ( res.ok ) {
onSuccess();
return;
}
if ( isSessionAuthError(res, data) ) {
onReauthRequired(data);
return;
}
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
await myOpenRevalidatePopup(data.revalidate_url);
const r = await doSubmit();
if ( r.ok ) onSuccess();
else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed'));
if ( r.ok ) {
onSuccess();
return;
}
const d = await r.json().catch(() => ({}));
if ( isSessionAuthError(r, d) ) onReauthRequired(d);
else onError(d.message || 'Request failed');
return;
}
onError(data.message || 'Request failed');
@@ -156,8 +167,10 @@ async function UIWindowChangeUsername (options) {
const new_username = $(el_window).find('.new-username').val();
const body = { new_username };
if ( password !== undefined && password !== '' ) body.password = password;
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie)
return fetch(apiUrl, {
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie).
// On a 401 caused by a missing/bad cookie the wrapper mints the cookie
// from the GUI bearer token via /session/sync-cookie and retries once.
const send = () => fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: {
@@ -165,6 +178,7 @@ async function UIWindowChangeUsername (options) {
},
body: JSON.stringify(body),
});
return fetchWithSessionCookieRetry(send, { origin, authToken: window.auth_token });
}
function onSuccess () {
@@ -184,6 +198,14 @@ async function UIWindowChangeUsername (options) {
$(el_window).find('.change-username-btn').removeClass('disabled');
$(el_window).find('.new-username, .change-username-password').attr('disabled', false);
}
// Session cookie is absent and couldn't be minted from the bearer
// token — force the sign-in flow so it gets set, then the user can
// retry the change.
function onReauthRequired (data) {
onError(i18n('reauth_required_message'));
window.handleReauthRequired({ reason: data.reason, auth_id: data.auth_id });
}
}
export default UIWindowChangeUsername;
+77
View File
@@ -0,0 +1,77 @@
/**
* 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/>.
*/
/**
* Helpers for `/user-protected/*` endpoints, which authenticate via the
* httpOnly session cookie only (bearer tokens are rejected so a leaked
* token can't drive account changes). The GUI sends these requests with
* cookies and no Authorization header, so a missing/stale cookie comes
* back as a 401 whose `code` depends on where it was rejected:
*
* - `token_missing` — no cookie at all (auth gate)
* - `token_auth_failed` — cookie present but invalid (auth gate)
* - `reauth_required` — cookie maps to a revoked/legacy session
* - `session_required` — authenticated via a non-cookie source
*
* All of these can usually be self-healed by minting a fresh cookie from
* the GUI bearer token via `/session/sync-cookie` and retrying once. When
* that isn't possible the caller should route through
* `window.handleReauthRequired` so the user signs in again and the cookie
* gets set.
*/
const SESSION_AUTH_CODES = new Set([
'token_missing',
'token_auth_failed',
'reauth_required',
'session_required',
]);
export const isSessionAuthError = (res, data) => {
return res?.status === 401 && SESSION_AUTH_CODES.has(data?.code);
};
export const syncSessionCookie = async ({ origin, authToken, fetchFn = globalThis.fetch }) => {
if ( ! authToken ) return false;
try {
const res = await fetchFn(`${origin}/session/sync-cookie`, {
method: 'GET',
credentials: 'include',
headers: { Authorization: `Bearer ${authToken}` },
});
return res.ok;
} catch ( e ) {
return false;
}
};
/**
* Run `send` (a thunk performing the user-protected fetch); on a 401
* caused by a missing/bad session cookie, mint the cookie and retry once.
*/
export const fetchWithSessionCookieRetry = async (send, { origin, authToken, fetchFn } = {}) => {
let res = await send();
if ( res.status === 401 ) {
const data = await res.clone().json().catch(() => ({}));
if ( isSessionAuthError(res, data) && await syncSessionCookie({ origin, authToken, fetchFn }) ) {
res = await send();
}
}
return res;
};
+144
View File
@@ -0,0 +1,144 @@
/*
* 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 { fetchWithSessionCookieRetry, isSessionAuthError, syncSessionCookie } from './sessionAuth.js';
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
describe('isSessionAuthError', () => {
it('matches every 401 shape a missing/bad session cookie produces', () => {
for ( const code of ['token_missing', 'token_auth_failed', 'reauth_required', 'session_required'] ) {
expect(isSessionAuthError({ status: 401 }, { code })).toBe(true);
}
});
it('rejects other statuses, other codes, and missing data', () => {
expect(isSessionAuthError({ status: 403 }, { code: 'session_required' })).toBe(false);
expect(isSessionAuthError({ status: 401 }, { code: 'password_required' })).toBe(false);
expect(isSessionAuthError({ status: 401 }, null)).toBe(false);
expect(isSessionAuthError(null, { code: 'session_required' })).toBe(false);
});
});
describe('syncSessionCookie', () => {
it('returns false without a bearer token and does not call fetch', async () => {
let called = false;
const ok = await syncSessionCookie({
origin: 'https://puter.test',
authToken: null,
fetchFn: () => { called = true; },
});
expect(ok).toBe(false);
expect(called).toBe(false);
});
it('calls /session/sync-cookie with the bearer token and cookies', async () => {
let seen = null;
const ok = await syncSessionCookie({
origin: 'https://puter.test',
authToken: 'tok-123',
fetchFn: (url, opts) => {
seen = { url, opts };
return Promise.resolve({ ok: true });
},
});
expect(ok).toBe(true);
expect(seen.url).toBe('https://puter.test/session/sync-cookie');
expect(seen.opts.credentials).toBe('include');
expect(seen.opts.headers.Authorization).toBe('Bearer tok-123');
});
it('returns false on a non-ok response or a network error', async () => {
expect(await syncSessionCookie({
origin: 'https://puter.test',
authToken: 'tok',
fetchFn: () => Promise.resolve({ ok: false }),
})).toBe(false);
expect(await syncSessionCookie({
origin: 'https://puter.test',
authToken: 'tok',
fetchFn: () => Promise.reject(new Error('offline')),
})).toBe(false);
});
});
describe('fetchWithSessionCookieRetry', () => {
it('returns a successful response without syncing', async () => {
let syncCalled = false;
const res = await fetchWithSessionCookieRetry(
() => Promise.resolve(jsonResponse(200, { success: true })),
{ origin: 'https://puter.test', authToken: 'tok', fetchFn: () => { syncCalled = true; } },
);
expect(res.ok).toBe(true);
expect(syncCalled).toBe(false);
});
it('retries once after minting the cookie on a session-auth 401', async () => {
// token_missing is the shape the GUI actually sees when the cookie
// is absent (the request carries no other credential).
for ( const code of ['token_missing', 'token_auth_failed', 'session_required'] ) {
let sends = 0;
let synced = false;
const res = await fetchWithSessionCookieRetry(
() => Promise.resolve(++sends === 1
? jsonResponse(401, { code })
: jsonResponse(200, { success: true })),
{
origin: 'https://puter.test',
authToken: 'tok',
fetchFn: () => {
synced = true;
return Promise.resolve({ ok: true });
},
},
);
expect(synced).toBe(true);
expect(sends).toBe(2);
expect(res.ok).toBe(true);
}
});
it('returns the original 401 when the cookie cannot be minted', async () => {
let sends = 0;
const res = await fetchWithSessionCookieRetry(
() => Promise.resolve((sends++, jsonResponse(401, { code: 'session_required' }))),
{ origin: 'https://puter.test', authToken: null },
);
expect(sends).toBe(1);
expect(res.status).toBe(401);
// Body must still be readable by the caller after the internal peek.
expect((await res.json()).code).toBe('session_required');
});
it('does not retry non-session 401 shapes', async () => {
let sends = 0;
let syncCalled = false;
const res = await fetchWithSessionCookieRetry(
() => Promise.resolve((sends++, jsonResponse(401, { code: 'unauthorized' }))),
{ origin: 'https://puter.test', authToken: 'tok', fetchFn: () => { syncCalled = true; } },
);
expect(sends).toBe(1);
expect(syncCalled).toBe(false);
expect(res.status).toBe(401);
});
});