From 438a4584de8499e1a43888c00d2f0a4aedaf1723 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 23 Sep 2026 16:39:22 -0400 Subject: [PATCH] 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 --- .../controllers/auth/AuthController.test.ts | 60 ++++++++++++++++--- .../controllers/auth/AuthController.ts | 15 +++-- .../http/middleware/privateAppGate.test.ts | 21 +++++++ .../core/http/middleware/privateAppGate.ts | 4 +- src/backend/drivers/apps/AppDriver.js | 12 ++++ src/backend/drivers/apps/AppDriver.test.ts | 28 +++++++++ src/backend/services/auth/AuthService.test.ts | 46 ++++++++++++++ src/backend/services/auth/AuthService.ts | 49 ++++++++++++--- 8 files changed, 214 insertions(+), 21 deletions(-) diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index a58a72b1b..32d64a278 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -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(); }); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 17b226dcf..c74267ad0 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -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 diff --git a/src/backend/core/http/middleware/privateAppGate.test.ts b/src/backend/core/http/middleware/privateAppGate.test.ts index e5e142c17..966775bd3 100644 --- a/src/backend/core/http/middleware/privateAppGate.test.ts +++ b/src/backend/core/http/middleware/privateAppGate.test.ts @@ -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'), diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index e75bd2c8b..cd0c95744 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -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; diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index f2d88470e..5c38e4e89 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -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. diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index 57f5b5406..e621d4208 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -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( diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index aede3e864..e53be2c89 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -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,', diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 7bbac112b..a06abe705 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -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;