diff --git a/src/backend/controllers/homepage/HomepageController.test.ts b/src/backend/controllers/homepage/HomepageController.test.ts index bac43df30..874cc392c 100644 --- a/src/backend/controllers/homepage/HomepageController.test.ts +++ b/src/backend/controllers/homepage/HomepageController.test.ts @@ -277,6 +277,43 @@ describe('HomepageController GET /app/:name', () => { expect(html).toMatch(//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/* ───────────────────────────────────────────────────────── diff --git a/src/backend/controllers/homepage/HomepageController.ts b/src/backend/controllers/homepage/HomepageController.ts index c0e3970b0..acdac4df7 100644 --- a/src/backend/controllers/homepage/HomepageController.ts +++ b/src/backend/controllers/homepage/HomepageController.ts @@ -101,6 +101,18 @@ export class HomepageController extends PuterController { ? safeJsonParse(app.metadata) : (app.metadata as Record | 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, + app: clientApp as Record, }); return; } diff --git a/src/backend/core/http/middleware/privateAppGate.test.ts b/src/backend/core/http/middleware/privateAppGate.test.ts index 81bd3ce09..f42fdeebb 100644 --- a/src/backend/core/http/middleware/privateAppGate.test.ts +++ b/src/backend/core/http/middleware/privateAppGate.test.ts @@ -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, diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index ba1e6cfe5..91626e9e4 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -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; diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index fac7fef71..8e493324a 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -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} app - raw app store row + * @param {any} [actor] - request actor, or null when anonymous + * @param {Record} [params] + */ + async toClientView(app, actor = null, params = {}) { + return this.#toClient(app, actor, params); + } + async #toClient(app, actor, params = {}) { if (!app) return null; diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index 20b2139fc..4713e40da 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -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, [ diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index d6aced461..08a2dea80 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -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!; diff --git a/src/backend/stores/systemKv/SystemKVStore.test.ts b/src/backend/stores/systemKv/SystemKVStore.test.ts index 9eca7be2c..5b728fa79 100644 --- a/src/backend/stores/systemKv/SystemKVStore.test.ts +++ b/src/backend/stores/systemKv/SystemKVStore.test.ts @@ -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( diff --git a/src/backend/stores/systemKv/SystemKVStore.ts b/src/backend/stores/systemKv/SystemKVStore.ts index fa2b7fbb9..a14a3c0dd 100644 --- a/src/backend/stores/systemKv/SystemKVStore.ts +++ b/src/backend/stores/systemKv/SystemKVStore.ts @@ -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 - | undefined; + Record | undefined; if (!startKey) { exhausted = remaining > 0; break; @@ -489,8 +484,7 @@ export class SystemKVStore extends PuterStore { >), ); nextKey = response.LastEvaluatedKey as - | Record - | undefined; + Record | 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 - | undefined; + Record | undefined; } while (countKey); } @@ -613,7 +606,7 @@ export class SystemKVStore extends PuterStore { KVResult> > { 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) { diff --git a/src/dev-center/index.html b/src/dev-center/index.html index 9e5153593..1d33e6b32 100644 --- a/src/dev-center/index.html +++ b/src/dev-center/index.html @@ -280,25 +280,24 @@

Users who have authenticated into

-
- - - - - - - - -
UsernameEmail
- -
+
+ + + + + + + + +
UsernameEmail
+
-
+
Close -
-
+ + diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index abbd5ee15..2db9e8b70 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -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; \ No newline at end of file diff --git a/src/gui/src/util/sessionAuth.js b/src/gui/src/util/sessionAuth.js new file mode 100644 index 000000000..24665a27f --- /dev/null +++ b/src/gui/src/util/sessionAuth.js @@ -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 . + */ + +/** + * 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; +}; diff --git a/src/gui/src/util/sessionAuth.test.js b/src/gui/src/util/sessionAuth.test.js new file mode 100644 index 000000000..4bec58bae --- /dev/null +++ b/src/gui/src/util/sessionAuth.test.js @@ -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 . + */ + +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); + }); +});