From c237e2433f88afd29e4429ef21132c901aed3e00 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 15 Jul 2026 16:38:53 -0400 Subject: [PATCH] fix: clean apps on subdomain deletion (#3392) --- src/backend/drivers/apps/AppDriver.js | 79 +++++++++++++-- src/backend/drivers/apps/AppDriver.test.ts | 112 +++++++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index de84082d6..2bd4f66a5 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -794,6 +794,51 @@ export class AppDriver extends PuterDriver { } } + /** + * Launch-safety check 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. Without this check the + * desktop would build the app iframe on a now-reclaimable origin and + * append the launch token to it (the GUI launcher appends + * `puter.auth.token` to the index_url), handing a valid token to whoever + * controls that subdomain today. + * + * Returns true when the app's hosted subdomain is missing, or is + * currently owned by a different user than the app's owner. Non-hosted + * index_urls (a developer's own external domain, builtins) return false: + * we don't manage their DNS and can't reason about their ownership. + */ + async #hostedIndexUrlBackingIsUnavailable(app) { + const subdomain = this.#extractPuterHostedSubdomain(app.index_url); + if (!subdomain) return false; + + let row = await this.stores.subdomain.getBySubdomain(subdomain); + if (!row) { + // A freshly-created subdomain may not have reached a replica or + // the local cache yet; confirm against the primary before + // treating the backing as gone (mirrors the create/update + // ownership check in `#ensurePuterSiteSubdomainIsOwned`). + row = await this.stores.subdomain.getBySubdomain(subdomain, { + primary: true, + }); + } + if (!row) return true; // subdomain no longer exists → dangling + + const appOwnerId = Number(app.owner_user_id); + const subdomainOwnerId = Number(row.user_id); + if ( + !Number.isInteger(appOwnerId) || + !Number.isInteger(subdomainOwnerId) + ) { + return true; + } + // Subdomain exists but belongs to someone other than the app owner — + // it was reclaimed; launching would leak the token to the new owner. + return subdomainOwnerId !== appOwnerId; + } + async #toClient(app, actor, params = {}) { if (!app) return null; @@ -801,12 +846,14 @@ export class AppDriver extends PuterDriver { // batched query and threads them through `params.filetypes` to // avoid the N+1 in this hot loop. Single-app callers (`read`, // `create`, `update`) fall back to the per-app query. - const [filetypes, canonicalForIndexUrl] = await Promise.all([ - params.filetypes !== undefined - ? Promise.resolve(params.filetypes) - : this.appStore.getFiletypeAssociations(app.id), - this.#resolveCanonicalForIndexUrl(app), - ]); + const [filetypes, canonicalForIndexUrl, hostedBackingUnavailable] = + await Promise.all([ + params.filetypes !== undefined + ? Promise.resolve(params.filetypes) + : this.appStore.getFiletypeAssociations(app.id), + this.#resolveCanonicalForIndexUrl(app), + this.#hostedIndexUrlBackingIsUnavailable(app), + ]); const createdFromOrigin = canonicalForIndexUrl && canonicalForIndexUrl.expectedUid === app.uid @@ -903,6 +950,26 @@ export class AppDriver extends PuterDriver { } } + // Hosted-subdomain launch guard (independent of the private-app + // gate): deny launch when the app's puter-hosted backing is gone or + // has been reclaimed by another user, so the GUI never appends the + // launch token to an origin the app owner no longer controls. Empty + // `fallbackAppName` keeps the launcher from redirecting to + // app-center — this isn't an entitlement problem, the backing is + // simply unavailable. Only set when not already denied so a private + // app's existing decision is preserved. + if ( + hostedBackingUnavailable && + result.privateAccess?.hasAccess !== false + ) { + result.privateAccess = { + hasAccess: false, + fallbackAppName: '', + reason: 'hosted_backing_unavailable', + checkedBy: 'core/hosted-subdomain-guard', + }; + } + return result; } diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index ec5249f1a..f4229ec65 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -1175,4 +1175,116 @@ describe('AppDriver hosted-subdomain ownership check', () => { ), ).rejects.toMatchObject({ statusCode: 400 }); }); + + // -- Launch guard: the backing subdomain can disappear AFTER the app + // was created (deleted by its owner, then reclaimable by anyone). The + // read path must refuse to launch so the GUI never appends the launch + // token to a now-reclaimable origin. + + it('denies launch when the hosted subdomain is later deleted', async () => { + const { actor, userId } = await makeUser(); + const sub = uniqueName('gone'); + const row = await server.stores.subdomain.create({ + userId, + subdomain: sub, + }); + + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('app'), + title: 'Backed App', + index_url: hostedUrl(sub), + }, + }), + ); + + // While the subdomain is still owned, the app launches normally. + const before = await withActor(actor, () => + driver.read({ uid: created.uid }), + ); + expect(String(before.index_url)).toContain(sub); + expect( + (before.privateAccess as { hasAccess?: boolean } | undefined) + ?.hasAccess, + ).not.toBe(false); + + // Delete the subdomain but keep the app pointing at it. + await server.stores.subdomain.deleteByUuid( + String((row as { uuid: string }).uuid), + { userId }, + ); + + const after = await withActor(actor, () => + driver.read({ uid: created.uid }), + ); + const access = after.privateAccess as { + hasAccess?: boolean; + reason?: string; + }; + expect(access?.hasAccess).toBe(false); + expect(access?.reason).toBe('hosted_backing_unavailable'); + }); + + it('denies launch when the hosted subdomain was reclaimed by another user', async () => { + const owner = await makeUser(); + const attacker = await makeUser(); + const sub = uniqueName('reclaim'); + 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), + }, + }), + ); + + // Owner deletes the subdomain; the attacker re-registers the name. + await server.stores.subdomain.deleteByUuid( + String((row as { uuid: string }).uuid), + { userId: owner.userId }, + ); + await server.stores.subdomain.create({ + userId: attacker.userId, + subdomain: sub, + }); + + const after = await withActor(owner.actor, () => + driver.read({ uid: created.uid }), + ); + expect( + (after.privateAccess as { hasAccess?: boolean }).hasAccess, + ).toBe(false); + }); + + it('keeps launching while the hosted subdomain is still owned', async () => { + const { actor, userId } = await makeUser(); + const sub = uniqueName('live'); + await server.stores.subdomain.create({ userId, subdomain: sub }); + + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('app'), + title: 'Backed App', + index_url: hostedUrl(sub), + }, + }), + ); + + const result = await withActor(actor, () => + driver.read({ uid: created.uid }), + ); + expect(String(result.index_url)).toContain(sub); + expect( + (result.privateAccess as { hasAccess?: boolean } | undefined) + ?.hasAccess, + ).not.toBe(false); + }); });