diff --git a/src/backend/controllers/apps/AppController.js b/src/backend/controllers/apps/AppController.js index 8abd6d8be..43719dd38 100644 --- a/src/backend/controllers/apps/AppController.js +++ b/src/backend/controllers/apps/AppController.js @@ -242,7 +242,13 @@ export class AppController extends PuterController { source: 'appsRoute', args: req.query ?? {}, }); - return { ...shaped, privateAccess }; + return { + ...shaped, + privateAccess: + shaped.privateAccess?.hasAccess === false + ? shaped.privateAccess + : privateAccess, + }; }), ); diff --git a/src/backend/controllers/apps/AppController.test.ts b/src/backend/controllers/apps/AppController.test.ts index 66d6012b8..950764e07 100644 --- a/src/backend/controllers/apps/AppController.test.ts +++ b/src/backend/controllers/apps/AppController.test.ts @@ -500,6 +500,73 @@ describe('AppController GET /apps/:name', () => { expect((arr[0] as Record).uid).toBe(app.uid); expect(arr[1]).toBeNull(); }); + + // The driver is the authoritative producer of launch metadata: it runs + // the hosted-subdomain guard, which this route knows nothing about. The + // route's own entitlement re-check must never turn that denial into an + // affirmative verdict — the GUI launcher appends `puter.auth.token` to + // whatever `index_url` it is handed. + + it('preserves the hosted-backing denial for the app owner', async () => { + const owner = await makeUser(); + const sub = uniqueName('gone'); + const row = await server.stores.subdomain.create({ + userId: owner.userId, + subdomain: sub, + }); + const app = await createApp(owner.actor, { + index_url: `https://${sub}.site.puter.localhost/`, + }); + await server.stores.subdomain.deleteByUuid( + String((row as { uuid: string }).uuid), + { userId: owner.userId }, + ); + + const { res, captured } = makeRes(); + await withActor(owner.actor, () => + callRoute( + 'get', + '/apps/:name', + makeReq({ params: { name: app.name }, actor: owner.actor }), + res, + ), + ); + const body = captured.body as Record; + expect(body.privateAccess).toMatchObject({ + hasAccess: false, + reason: 'hosted_backing_unavailable', + }); + }); + + it('omits the stale index_url for a non-owner', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const sub = uniqueName('gone'); + const row = await server.stores.subdomain.create({ + userId: owner.userId, + subdomain: sub, + }); + const app = await createApp(owner.actor, { + index_url: `https://${sub}.site.puter.localhost/`, + }); + await server.stores.subdomain.deleteByUuid( + String((row as { uuid: string }).uuid), + { userId: owner.userId }, + ); + + const { res, captured } = makeRes(); + await withActor(stranger.actor, () => + callRoute( + 'get', + '/apps/:name', + makeReq({ params: { name: app.name }, actor: stranger.actor }), + res, + ), + ); + const body = captured.body as Record; + expect(body.index_url).toBeUndefined(); + expect(body.privateAccess).toMatchObject({ hasAccess: false }); + }); }); // ── POST /query/app ───────────────────────────────────────────────── diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index f0089bce2..62db4808d 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -980,11 +980,13 @@ export class AppDriver extends PuterDriver { // launch token to an origin the app owner no longer controls. Only // set when not already denied so a private app's existing decision // is preserved. - if ( - hostedBackingUnavailable && - result.privateAccess?.hasAccess !== false - ) { - result.privateAccess = buildHostedBackingDenial(); + if (hostedBackingUnavailable) { + if (result.privateAccess?.hasAccess !== false) { + result.privateAccess = buildHostedBackingDenial(); + } + if (actor?.user?.id !== app.owner_user_id) { + delete result.index_url; + } } return result; diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index 28ac9b3ed..98beab6c7 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -1520,6 +1520,48 @@ describe('AppDriver hosted-subdomain ownership check', () => { expect(access?.reason).toBe('hosted_backing_unavailable'); }); + it('withholds the stale index_url from everyone but the owner', async () => { + const owner = await makeUser(); + const other = await makeUser(); + const sub = uniqueName('stale'); + const row = await server.stores.subdomain.create({ + userId: owner.userId, + subdomain: sub, + }); + + const created = await withActor(owner.actor, () => + driver.create({ + object: { + name: uniqueName('app'), + title: 'Backed App', + index_url: hostedUrl(sub), + }, + }), + ); + await server.stores.subdomain.deleteByUuid( + String((row as { uuid: string }).uuid), + { userId: owner.userId }, + ); + + // The owner still sees it — dev center renders the URL in the app's + // edit form, and it's their row to repoint. + const asOwner = await withActor(owner.actor, () => + driver.read({ uid: created.uid }), + ); + expect(String(asOwner.index_url)).toContain(sub); + + // Anyone else gets the denial without the URL it suppresses, so a + // consumer that reads `index_url` without reading the verdict still + // can't hand it to the launcher. + const asOther = await withActor(other.actor, () => + driver.read({ uid: created.uid }), + ); + expect(asOther.index_url).toBeUndefined(); + expect( + (asOther.privateAccess as { hasAccess?: boolean }).hasAccess, + ).toBe(false); + }); + it('denies launch when the hosted subdomain was reclaimed by another user', async () => { const owner = await makeUser(); const attacker = await makeUser(); diff --git a/src/backend/drivers/subdomain/SubdomainDriver.test.ts b/src/backend/drivers/subdomain/SubdomainDriver.test.ts index 0d563c6e1..001f6b771 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.test.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.test.ts @@ -601,7 +601,7 @@ describe('SubdomainDriver.delete', () => { // an `index_url` match against the subdomain's host variants. const createAppWithIndexUrl = async ( - ownerUserId: number, + ownerUserId: number | null, indexUrl: string, opts: { isPrivate?: boolean } = {}, ): Promise<{ id: number; uid: string }> => { @@ -626,6 +626,104 @@ const createAppWithIndexUrl = async ( return row; }; +// ── Launch-origin takeover on re-registration ────────────────────── +// +// Deleting a hosted subdomain leaves the app row's `index_url` pointing +// at the freed name. The GUI launcher appends `puter.auth.token` to that +// URL, so whoever registers the name next would receive launch tokens +// for the original app. `create` therefore refuses a name another user's +// app still references — while leaving the app's own owner free to +// re-create it. + +describe('SubdomainDriver.create launch-origin reservation', () => { + it("refuses a name another user's app still points at", async () => { + const victim = await makeUser(); + const attacker = await makeUser(); + const sub = uniqueSubdomain('freed'); + + await createAppWithIndexUrl( + victim.userId, + `http://${sub}.site.puter.localhost/`, + ); + + await expect( + withActor(attacker.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${attacker.actor.user!.username}/Public`, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 409 }); + expect(await server.stores.subdomain.getBySubdomain(sub)).toBeFalsy(); + }); + + it('refuses a name an unowned origin-bootstrapped app points at', async () => { + const attacker = await makeUser(); + const sub = uniqueSubdomain('orphan'); + + await createAppWithIndexUrl( + null, + `http://${sub}.site.puter.localhost`, + ); + + await expect( + withActor(attacker.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${attacker.actor.user!.username}/Public`, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 409 }); + }); + + it('lets the referencing app owner re-create the name', async () => { + const owner = await makeUser(); + const sub = uniqueSubdomain('recreate'); + + await createAppWithIndexUrl( + owner.userId, + `http://${sub}.site.puter.localhost/`, + ); + + const created = (await withActor(owner.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${owner.actor.user!.username}/Public`, + }, + }), + )) as Record; + expect(created.subdomain).toBe(sub); + }); + + it('leaves names no app references claimable', async () => { + const other = await makeUser(); + const claimant = await makeUser(); + const sub = uniqueSubdomain('unrelated'); + + // Same owner, unrelated host: the reservation keys off the name, + // not off the existence of other people's apps. + await createAppWithIndexUrl( + other.userId, + 'https://elsewhere.example.test/', + ); + + const created = (await withActor(claimant.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${claimant.actor.user!.username}/Public`, + }, + }), + )) as Record; + expect(created.subdomain).toBe(sub); + }); +}); + describe('SubdomainDriver associated_app derivation', () => { it('ignores `associated_app_uid` on create and derives null when no app matches', async () => { const { actor } = await makeUser(); @@ -706,10 +804,14 @@ describe('SubdomainDriver associated_app derivation', () => { }); it("does not derive an `associated_app` for another user's app at the same host", async () => { - // The core IDOR: attacker (a) sets up a subdomain whose host - // matches the index_url of a victim's (b) private app. Even on - // exact index_url match, the ownership filter must reject the - // app from another user's account. + // The core IDOR: attacker (a) holds a subdomain whose host matches + // the index_url of a victim's (b) private app. Even on exact + // index_url match, the ownership filter must reject the app from + // another user's account. + // + // `create` now refuses that pairing outright (see the launch-origin + // reservation above), so the row is planted through the store — this + // is the legacy-data shape the derive filter still has to handle. const a = await makeUser(); const b = await makeUser(); const sub = uniqueSubdomain('idor'); @@ -720,15 +822,15 @@ describe('SubdomainDriver associated_app derivation', () => { { isPrivate: true }, ); - const created = (await withActor(a.actor, () => - driver.create({ - object: { - subdomain: sub, - root_dir: `/${a.actor.user!.username}/Public`, - }, - }), + const row = await server.stores.subdomain.create({ + userId: a.userId, + subdomain: sub, + }); + + const read = (await withActor(a.actor, () => + driver.read({ uid: (row as { uuid: string }).uuid }), )) as Record; - expect(created.associated_app).toBeNull(); + expect(read.associated_app).toBeNull(); }); }); diff --git a/src/backend/drivers/subdomain/SubdomainDriver.ts b/src/backend/drivers/subdomain/SubdomainDriver.ts index 00e69c67d..6a4ea4c53 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.ts @@ -31,6 +31,7 @@ import type { DriverRateLimitConfig } from '../meta.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import type { UserRow } from '../../stores/user/UserStore.js'; import { expandTildePath } from '../../services/fs/resolveNode.js'; +import { buildHostedSubdomainIndexUrlCandidates } from '../../util/hostedAppBacking.js'; import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js'; import { decodeCursor, @@ -147,6 +148,31 @@ export class SubdomainDriver extends PuterDriver { }); } await this.services.fs.checkFSAccess(entry, actor); + + // A name some other user's app still points at is not free either. + // Deleting a hosted subdomain leaves the app row's `index_url` intact, + // and the GUI launcher appends `puter.auth.token` to whatever URL it is + // given — so registering the freed name would hand that app's launch + // token to whoever claimed it. The app's own owner is exempt: + // re-creating their site restores their app rather than hijacking it. + // See `util/hostedAppBacking.ts` for the wider rule. + // + // Last check before the insert on purpose: `apps.index_url` is + // unindexed, so this scan only runs for a request that would otherwise + // have created the row, and it stays behind the same root_dir gate as + // the existing uniqueness answer. + const appHoldingName = await this.stores.app.findByIndexUrlCandidates( + buildHostedSubdomainIndexUrlCandidates(subdomain, this.config), + { excludeOwnerUserId: actor.user.id }, + ); + if (appHoldingName) { + throw new HttpError( + 409, + 'A site with this subdomain already exists', + { legacyCode: 'conflict' }, + ); + } + // `associated_app_id` is no longer accepted from clients. The // "associated app" for a subdomain is derived at read time from // `apps.owner_user_id = subdomain.user_id` + `index_url` match @@ -604,37 +630,6 @@ export class SubdomainDriver extends PuterDriver { const result = new Map(); if (rows.length === 0) return result; - const normalize = (v: unknown): string | null => { - if (typeof v !== 'string') return null; - const trimmed = v.trim().toLowerCase().replace(/^\./, ''); - return trimmed || null; - }; - const stripPort = (v: string): string => v.split(':')[0] || v; - - const hostingDomainsRaw = [ - normalize(this.config.static_hosting_domain), - normalize(this.config.static_hosting_domain_alt), - normalize(this.config.private_app_hosting_domain), - normalize(this.config.private_app_hosting_domain_alt), - ].filter((d): d is string => !!d); - const hostingDomains = [ - ...new Set([ - ...hostingDomainsRaw, - ...hostingDomainsRaw.map(stripPort), - ]), - ]; - if (hostingDomains.length === 0) return result; - - const configuredProtocol = - typeof this.config.protocol === 'string' - ? this.config.protocol.trim().replace(/:$/, '') - : ''; - const protocols = [ - ...new Set( - [configuredProtocol, 'https', 'http'].filter((p) => !!p), - ), - ]; - const userIdToRowMeta = new Map< number, Array<{ rowUuid: string; candidates: Set }> @@ -656,16 +651,10 @@ export class SubdomainDriver extends PuterDriver { : ''; if (!subdomain || !Number.isFinite(userId) || !rowUuid) continue; - const candidates = new Set(); - for (const d of hostingDomains) { - const host = `${subdomain}.${d}`; - for (const p of protocols) { - const base = `${p}://${host}`; - candidates.add(base); - candidates.add(`${base}/`); - candidates.add(`${base}/index.html`); - } - } + const candidates = new Set( + buildHostedSubdomainIndexUrlCandidates(subdomain, this.config), + ); + if (candidates.size === 0) continue; for (const c of candidates) allCandidates.add(c); if (!userIdToRowMeta.has(userId)) { diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js index 6e97a12ae..0124c04a5 100644 --- a/src/backend/stores/app/AppStore.js +++ b/src/backend/stores/app/AppStore.js @@ -254,10 +254,18 @@ export class AppStore extends PuterStore { * Find the oldest app whose `index_url` matches one of `candidates`. Used * by the driver to detect duplicate puter-hosted index_url rows * (origin-bootstrap apps + same-owner duplicates) so they can be merged on - * `create` / `update`. Returns the minimal row shape needed by the merge - * path; falsy when nothing matches. + * `create` / `update`, and by the subdomain driver to refuse a name another + * user's app still points at. Returns the minimal row shape needed by the + * merge path; falsy when nothing matches. + * + * `excludeOwnerUserId` skips rows owned by that user; unowned rows + * (origin-bootstrap apps) still match, since their launch origin is no less + * takeover-sensitive for having no owner. */ - async findByIndexUrlCandidates(candidates, { excludeAppId } = {}) { + async findByIndexUrlCandidates( + candidates, + { excludeAppId, excludeOwnerUserId } = {}, + ) { if (!Array.isArray(candidates) || candidates.length === 0) return null; const placeholders = candidates.map(() => '?').join(', '); const params = [...candidates]; @@ -266,6 +274,10 @@ export class AppStore extends PuterStore { sql += ' AND `id` != ?'; params.push(excludeAppId); } + if (Number.isInteger(excludeOwnerUserId) && excludeOwnerUserId > 0) { + sql += ' AND (`owner_user_id` IS NULL OR `owner_user_id` != ?)'; + params.push(excludeOwnerUserId); + } sql += ' ORDER BY `timestamp` ASC, `id` ASC LIMIT 1'; const rows = await this.clients.db.read(sql, params); return rows[0] ?? null; diff --git a/src/backend/util/hostedAppBacking.test.ts b/src/backend/util/hostedAppBacking.test.ts index 5d5794970..8ed6745f7 100644 --- a/src/backend/util/hostedAppBacking.test.ts +++ b/src/backend/util/hostedAppBacking.test.ts @@ -20,6 +20,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildHostedBackingDenial, + buildHostedSubdomainIndexUrlCandidates, extractPuterHostedSubdomain, getPuterHostedDomains, hostedIndexUrlBackingIsUnavailable, @@ -67,6 +68,59 @@ describe('getPuterHostedDomains', () => { }); }); +describe('buildHostedSubdomainIndexUrlCandidates', () => { + it('covers every hosting domain, protocol and path variant', () => { + const candidates = buildHostedSubdomainIndexUrlCandidates('myapp', { + static_hosting_domain: 'puter.site', + protocol: 'https:', + }); + expect(candidates).toEqual( + expect.arrayContaining([ + 'https://myapp.puter.site', + 'https://myapp.puter.site/', + 'https://myapp.puter.site/index.html', + 'http://myapp.puter.site', + 'http://myapp.puter.site/', + 'http://myapp.puter.site/index.html', + ]), + ); + // Nothing beyond the three path shapes the app write path accepts. + expect(candidates).toHaveLength(6); + }); + + it('keeps both the ported and bare host for dev configs', () => { + const candidates = buildHostedSubdomainIndexUrlCandidates('dev', { + static_hosting_domain: 'site.puter.localhost:4100', + protocol: 'http', + }); + expect(candidates).toContain('http://dev.site.puter.localhost:4100/'); + expect(candidates).toContain('http://dev.site.puter.localhost/'); + }); + + it('spans the alt and private hosting domains', () => { + const candidates = buildHostedSubdomainIndexUrlCandidates('x', CONFIG); + expect(candidates).toContain('https://x.puter.site/'); + expect(candidates).toContain('https://x.site.puter.localhost/'); + expect(candidates).toContain('https://x.private.puter.localhost/'); + }); + + it('normalizes the name and refuses unusable input', () => { + expect( + buildHostedSubdomainIndexUrlCandidates(' MyApp ', { + static_hosting_domain: 'puter.site', + }), + ).toContain('https://myapp.puter.site/'); + expect(buildHostedSubdomainIndexUrlCandidates('', CONFIG)).toEqual([]); + expect(buildHostedSubdomainIndexUrlCandidates(null, CONFIG)).toEqual( + [], + ); + expect(buildHostedSubdomainIndexUrlCandidates(42, CONFIG)).toEqual([]); + // No hosting domain configured — nothing can be hosted, so nothing + // is reserved. + expect(buildHostedSubdomainIndexUrlCandidates('x', {})).toEqual([]); + }); +}); + describe('extractPuterHostedSubdomain', () => { it('extracts the label from a hosted url', () => { expect( diff --git a/src/backend/util/hostedAppBacking.ts b/src/backend/util/hostedAppBacking.ts index 4ae9c2c81..c614d4acc 100644 --- a/src/backend/util/hostedAppBacking.ts +++ b/src/backend/util/hostedAppBacking.ts @@ -23,13 +23,19 @@ import type { PrivateLaunchDecision } from './privateLaunchAccess'; /** * Launch-safety checks for puter-hosted `index_url`s. * - * A hosted subdomain (`*.`) can be deleted by its owner and - * then re-registered by anyone else, but the app row keeps the stale URL — - * nothing rewrites it on subdomain deletion. Any producer of launchable app - * metadata must therefore re-check the backing before handing an `index_url` to - * the GUI launcher, which appends `puter.auth.token` to it. + * A hosted subdomain (`*.`) can be deleted by its owner, but + * the app row keeps the stale URL — nothing rewrites it on subdomain deletion. + * Any producer of launchable app metadata must therefore re-check the backing + * before handing an `index_url` to the GUI launcher, which appends + * `puter.auth.token` to it. * - * This module is the single home for that check. `AppDriver` was the first + * The other half of the rule lives on the write side: `SubdomainDriver.create` + * refuses a name that some other user's app still points at + * (`buildHostedSubdomainIndexUrlCandidates`), so a freed name can't be + * re-registered under someone else's launch origin. Only the app's own owner + * may re-create it, which restores their app. + * + * This module is the single home for both checks. `AppDriver` was the first * caller; `SuggestedAppsService` and `/get-launch-apps` build their own * summaries and need the same guard, so keep the logic here rather than * re-deriving it per producer. @@ -42,6 +48,7 @@ interface HostedDomainConfig { static_hosting_domain_alt?: unknown; private_app_hosting_domain?: unknown; private_app_hosting_domain_alt?: unknown; + protocol?: unknown; } interface AppBackingRow { @@ -49,33 +56,91 @@ interface AppBackingRow { owner_user_id?: unknown; } -function normalizeConfiguredHostedDomain(domainValue: unknown): string | null { +function normalizeHostedDomainValue(domainValue: unknown): string | null { if (typeof domainValue !== 'string') return null; const normalizedDomain = domainValue .trim() .toLowerCase() .replace(/^\./, ''); + return normalizedDomain || null; +} + +function normalizeConfiguredHostedDomain(domainValue: unknown): string | null { + const normalizedDomain = normalizeHostedDomainValue(domainValue); if (!normalizedDomain) return null; return normalizedDomain.split(':')[0] || null; } +function configuredHostedDomainValues( + config: HostedDomainConfig | undefined | null, +): unknown[] { + const cfg = config ?? {}; + return [ + cfg.static_hosting_domain, + cfg.static_hosting_domain_alt, + cfg.private_app_hosting_domain, + cfg.private_app_hosting_domain_alt, + ]; +} + export function getPuterHostedDomains( config: HostedDomainConfig | undefined | null, ): string[] { const domains = new Set(); - const cfg = config ?? {}; - for (const configuredDomain of [ - cfg.static_hosting_domain, - cfg.static_hosting_domain_alt, - cfg.private_app_hosting_domain, - cfg.private_app_hosting_domain_alt, - ]) { + for (const configuredDomain of configuredHostedDomainValues(config)) { const normalized = normalizeConfiguredHostedDomain(configuredDomain); if (normalized) domains.add(normalized); } return [...domains]; } +/** + * Every `index_url` string that resolves to `subdomain` on one of our hosting + * domains — protocol, port and trailing-path variants included. + * + * `apps.index_url` is matched by exact string, so a caller asking "does any app + * still point at this name?" has to enumerate the same shapes the app write + * path accepts. + */ +export function buildHostedSubdomainIndexUrlCandidates( + subdomain: unknown, + config: HostedDomainConfig | undefined | null, +): string[] { + const name = + typeof subdomain === 'string' ? subdomain.trim().toLowerCase() : ''; + if (!name) return []; + + const hosts = new Set(); + for (const configuredDomain of configuredHostedDomainValues(config)) { + // Keep the configured form and a port-stripped one: dev configs carry + // a `:4100`-style port and stored index_urls exist in both shapes. + const withPort = normalizeHostedDomainValue(configuredDomain); + if (withPort) hosts.add(`${name}.${withPort}`); + const bare = normalizeConfiguredHostedDomain(configuredDomain); + if (bare) hosts.add(`${name}.${bare}`); + } + if (hosts.size === 0) return []; + + const configuredProtocol = + typeof config?.protocol === 'string' + ? config.protocol.trim().replace(/:$/, '') + : ''; + const protocols = [ + ...new Set([configuredProtocol, 'https', 'http'].filter(Boolean)), + ]; + + const candidates = new Set(); + for (const host of hosts) { + for (const protocol of protocols) { + const base = `${protocol}://${host}`; + candidates.add(base); + candidates.add(`${base}/`); + candidates.add(`${base}/index.html`); + } + } + return [...candidates]; +} + /** * Returns the subdomain label when `indexUrl` is hosted on one of our * configured hosting domains, else null (a developer's own external domain, or