fix(hosting): keep alternate-host bootstrap stubs from shadowing private apps

A public origin-bootstrap app row planted on an alternate hosting domain
could resolve ahead of the owner's private app and skip the private
access gate (PUT-1883).

- get-user-app-token canonicalizes hosted-subdomain origins before uid
  derivation and bootstrap, so every hosting variant shares one app row
- the app create/update conflict check crosses hosting-domain variants,
  so an existing alt-host stub is absorbed by the owner's create
- resolveOwnedAppForHostedSite and the canonical-uid lookup order
  matches private-first with a deterministic id tiebreak
This commit is contained in:
Juan Castro committed 2026-09-23 16:39:22 -04:00
1 parent bc0f4dc279
commit 438a4584de
8 files changed
+214 -21

No files matched your search

@@ -3119,6 +3119,39 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
expect(bootstrapped).toBeTruthy();
expect(bootstrapped?.owner_user_id).toBe(owner!.id);
});
it('canonicalizes alternate-host origins to one bootstrap row per subdomain', async () => {
const subdomain = `sd-${uuidv4().slice(0, 8)}`;
await server.stores.subdomain.create({ userId: user.id, subdomain });
const res = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq(
{ origin: `https://${subdomain}.host.puter.localhost` },
{ actor },
),
res,
),
);
const body = res.body as { token: string; app_uid: string };
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
expect(bootstrapped?.index_url).toBe(
`http://${subdomain}.site.puter.localhost`,
);
const res2 = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq(
{ origin: `https://${subdomain}.app.puter.localhost` },
{ actor },
),
res2,
),
);
expect((res2.body as { app_uid: string }).app_uid).toBe(body.app_uid);
});
});
// ── Access tokens: create + revoke ─────────────────────────────────
@@ -5112,7 +5145,9 @@ describe('AuthController password recovery', () => {
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
const after = await server.stores.user.getById(seat.id, { force: true });
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeFalsy();
});
@@ -5377,7 +5412,10 @@ describe('AuthController user-protected mutations (validation paths)', () => {
await expect(
controller.handleChangeEmail(
makeReq({ new_email: `moved_${uniq()}@example.com` }, { actor }),
makeReq(
{ new_email: `moved_${uniq()}@example.com` },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
@@ -5405,7 +5443,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
),
).rejects.toMatchObject({ statusCode: 403 });
const after = await server.stores.user.getById(seat.id, { force: true });
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.username).toBe(seat.username);
});
@@ -5951,10 +5991,7 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
await inCtx(actor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission, extra: {} },
{ actor },
),
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
makeRes(),
),
);
@@ -5991,7 +6028,10 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
inCtx(appActor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: ['service:foo:ii:read'], app_uid: app.uid },
{
permissions: ['service:foo:ii:read'],
app_uid: app.uid,
},
{ actor: appActor },
),
makeRes(),
@@ -6836,7 +6876,9 @@ describe('AuthController.handleDeleteOwnUser', () => {
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 403 });
const after = await server.stores.user.getById(seat.id, { force: true });
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after).toBeTruthy();
});
+11 -4
View File
@@ -3908,11 +3908,18 @@ export class AuthController extends PuterController {
if (!app && resolvedFromOrigin) {
// Hosted-subdomain origins get the site owner stamped as the
// app's creator at bootstrap; external origins stay unowned.
// Canonical origin only, so alternate hosts share one row.
const canonicalOrigin =
this.services.auth.canonicalizeOrigin(origin);
const ownerUserId =
await this.services.auth.subdomainOwnerIdFromOrigin(origin);
app = await this.stores.app.createFromOrigin(app_uid, origin, {
ownerUserId,
});
await this.services.auth.subdomainOwnerIdFromOrigin(
canonicalOrigin,
);
app = await this.stores.app.createFromOrigin(
app_uid,
canonicalOrigin,
{ ownerUserId },
);
// An origin's uid is a deterministic uuidv5, so a deleted app
// reappears here under the identical uid. Withdraw any cross-app
// data grants left pointing at it before this new row can inherit
@@ -679,6 +679,27 @@ describe('resolveOwnedAppForHostedSite', () => {
expect(out).toBeNull();
});
it('prefers the private app over an older public bootstrap stub on an alternate host', async () => {
const owner = await makeUser();
await server.stores.app.createFromOrigin(
`app-${uuidv4()}`,
'http://beans.host.puter.localhost',
{ ownerUserId: owner.id },
);
const app = await createPrivateApp(
owner.id,
'http://beans.app.puter.localhost/',
);
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.app.puter.localhost'),
site: { user_id: owner.id },
db: server.clients.db,
config: baseConfig(),
});
expect(out?.uid).toBe(app.uid);
expect(Boolean(out?.is_private)).toBe(true);
});
it('returns null when the site has no owner', async () => {
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
@@ -249,8 +249,10 @@ export async function resolveOwnedAppForHostedSite(opts: {
? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} `
: '';
const placeholders = uniqueCandidates.map(() => '?').join(', ');
// Ambiguity fails closed: a private row wins; `id` keeps it deterministic.
const orderClause = `ORDER BY CASE WHEN \`is_private\` = ${opts.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\``;
const rows = await opts.db.read(
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) ${orderClause} LIMIT 2`,
[opts.site.user_id, ...uniqueCandidates],
);
if (rows.length === 0) return null;
+12
View File
@@ -32,6 +32,7 @@ import {
import { isUniqueViolation } from '../../util/dbError.js';
import {
buildHostedBackingDenial,
buildHostedSubdomainIndexUrlCandidates,
extractPuterHostedSubdomain,
hostedIndexUrlBackingIsUnavailable,
} from '../../util/hostedAppBacking.js';
@@ -1169,6 +1170,17 @@ export class AppDriver extends PuterDriver {
this.#buildEquivalentIndexUrlCandidates(indexUrl),
);
// The same subdomain on any other hosting domain is the same site.
const hostedSubdomain = this.#extractPuterHostedSubdomain(indexUrl);
if (hostedSubdomain) {
for (const candidate of buildHostedSubdomainIndexUrlCandidates(
hostedSubdomain,
this.config,
)) {
candidates.add(candidate);
}
}
// For alias-group hosts, treat the group as a host-level reservation:
// any row whose index_url is the root URL of any group member counts
// as a conflict, so a single app owns the whole group.
@@ -1530,6 +1530,34 @@ describe('AppDriver hosted-subdomain ownership check', () => {
expect(stored?.owner_user_id).toBe(userId);
});
it('create absorbs a bootstrap stub minted on an alternate hosting domain', async () => {
const { actor, userId } = await makeUser();
const sub = uniqueName('altstub');
await server.stores.subdomain.create({ userId, subdomain: sub });
const stubUid = `app-${uuidv4()}`;
await server.stores.app.createFromOrigin(
stubUid,
`https://${sub}.host.puter.localhost`,
{ ownerUserId: userId },
);
const name = uniqueName('alt-create');
const result = await withActor(actor, () =>
driver.create({
object: {
name,
title: 'Alt-host stub',
index_url: hostedUrl(sub),
},
}),
);
expect(result.uid).toBe(stubUid);
expect(result.name).toBe(name);
const stored = await server.stores.app.getByUid(stubUid);
expect(stored?.index_url).toBe(hostedUrl(sub));
});
it('rejects a hosted index_url whose subdomain does not exist anywhere', async () => {
const { actor } = await makeUser();
await expect(
@@ -1626,6 +1626,52 @@ describe('AuthService (integration)', () => {
expect(a).toMatch(/^app-/);
});
it('resolves every hosting variant of a subdomain to the same uid', async () => {
const sub = `canon-${Math.random().toString(36).slice(2, 10)}`;
const uids = await Promise.all([
authService.appUidFromOrigin(
`https://${sub}.site.puter.localhost`,
),
authService.appUidFromOrigin(
`https://${sub}.host.puter.localhost`,
),
authService.appUidFromOrigin(
`http://${sub}.app.puter.localhost`,
),
authService.appUidFromOrigin(
`https://${sub}.dev.puter.localhost`,
),
]);
expect(new Set(uids).size).toBe(1);
});
it('prefers the private app row over an older public stub across hosting variants', async () => {
const user = await makeUser();
const sub = `pref-${Math.random().toString(36).slice(2, 10)}`;
await server.stores.app.createFromOrigin(
`app-${uuidv4()}`,
`https://${sub}.host.puter.localhost`,
{ ownerUserId: user.id },
);
const realUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`, `is_private`) VALUES (?, ?, ?, ?, ?, ?)',
[
realUid,
`real-${sub}`,
'Real app',
`https://${sub}.app.puter.localhost`,
user.id,
1,
],
);
await expect(
authService.appUidFromOrigin(
`https://${sub}.host.puter.localhost`,
),
).resolves.toBe(realUid);
});
it.each([
'javascript:alert(document.domain)',
'data:text/html,<script>alert(1)</script>',
+42 -7
View File
@@ -782,11 +782,11 @@ export class AuthService extends PuterService {
legacyCode: 'bad_request',
});
}
// Aliased hosts collapse to a single canonical representative so the
// event listeners and the UUIDv5 fallback resolve to the same value
// for every member of an alias group.
// Aliased hosts and hosting-domain variants collapse to one canonical
// origin, so every spelling of the same app resolves to one uid.
const aliased = this.#canonicalizeAliasedOrigin(parsed) ?? parsed;
const event = { origin: aliased };
const canonical = this.#canonicalizeHostedOrigin(aliased) ?? aliased;
const event = { origin: canonical };
await this.clients.event?.emitAndWait('app.from-origin', event, {});
// Blocked origins can't acquire an app token (or have one minted /
@@ -920,6 +920,39 @@ export class AuthService extends PuterService {
return `${parsed.protocol}//${parsed.hostname}${port}`;
}
/** Same subdomain on the primary hosting domain; null when not hosted. */
#canonicalizeHostedOrigin(origin: string): string | null {
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
return null;
}
const hostingDomains = this.#getHostingDomains();
const subdomain = this.#hostedSubdomainForHost(
parsed.host.toLowerCase(),
parsed.hostname.toLowerCase(),
hostingDomains,
);
if (!subdomain) return null;
const canonicalDomain = hostingDomains[0];
if (!canonicalDomain) return null;
const config = this.config as { protocol?: string };
const protocol =
(typeof config.protocol === 'string'
? config.protocol.trim().replace(/:$/, '')
: '') || 'https';
return `${protocol}://${subdomain}.${canonicalDomain}`;
}
/** Canonical origin across alias groups and hosting domains. */
canonicalizeOrigin(origin: string): string {
const parsed = this.#originFromUrl(origin);
if (!parsed) return origin;
const aliased = this.#canonicalizeAliasedOrigin(parsed) ?? parsed;
return this.#canonicalizeHostedOrigin(aliased) ?? aliased;
}
/**
* Configured hosting domains (`static_hosting_domain(_alt)` +
* `private_app_hosting_domain(_alt)`), normalized, each in both raw
@@ -978,8 +1011,8 @@ export class AuthService extends PuterService {
*
* Build candidate URLs from the origin's subdomain crossed with every
* configured hosting domain (static + private, with and without ports).
* Prefer the oldest matching app for deterministic tie-breaking across
* historically-duplicated rows.
* Prefer private rows, then the oldest match, for deterministic
* tie-breaking across historically-duplicated rows.
*/
async #findCanonicalAppUidForOrigin(
origin: string,
@@ -1040,8 +1073,10 @@ export class AuthService extends PuterService {
if (uniqueCandidates.length === 0) return null;
const placeholders = uniqueCandidates.map(() => '?').join(', ');
// Private rows win over public duplicates; oldest id breaks ties.
const rows = (await this.clients.db.read(
`SELECT \`uid\` FROM \`apps\` WHERE \`index_url\` IN (${placeholders}) ORDER BY \`id\` ASC LIMIT 1`,
`SELECT \`uid\` FROM \`apps\` WHERE \`index_url\` IN (${placeholders}) ` +
`ORDER BY CASE WHEN \`is_private\` = ${this.clients.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\` ASC LIMIT 1`,
uniqueCandidates,
)) as Array<{ uid?: string }>;
const uid = rows[0]?.uid;