mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 06:58:21 +00:00
fix: clean apps on subdomain deletion (#3392)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user