fix: clean apps on subdomain deletion (#3392)

This commit is contained in:
Daniel Salazar
2026-07-15 13:38:53 -07:00
committed by GitHub
parent a4bb57bb79
commit c237e2433f
2 changed files with 185 additions and 6 deletions
+73 -6
View File
@@ -794,6 +794,51 @@ export class AppDriver extends PuterDriver {
}
}
/**
* Launch-safety check for puter-hosted `index_url`s.
*
* A hosted subdomain (`*.<hosting-domain>`) 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;
}
+112
View File
@@ -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);
});
});