diff --git a/src/backend/core/http/middleware/hostRedirects.test.ts b/src/backend/core/http/middleware/hostRedirects.test.ts index fc5eecaaf..adbd7d9cc 100644 --- a/src/backend/core/http/middleware/hostRedirects.test.ts +++ b/src/backend/core/http/middleware/hostRedirects.test.ts @@ -29,6 +29,7 @@ import { createNativeAppStatic, createUserSubdomainNotFound, createWwwRedirect, + subdomainLabelOf, type ResolvedClaim, type SubdomainClaim, } from './hostRedirects'; @@ -650,6 +651,32 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => { }); }); +describe('subdomainLabelOf', () => { + it('returns the single label under the domain, ignoring case and port', () => { + expect(subdomainLabelOf('shop.puter.com', 'puter.com')).toBe('shop'); + expect(subdomainLabelOf('SHOP.Puter.com:4100', 'puter.com')).toBe( + 'shop', + ); + expect(subdomainLabelOf('shop.puter.com', 'Puter.com:443')).toBe( + 'shop', + ); + }); + + it('returns undefined for anything but exactly one label under the domain', () => { + for (const host of [ + 'puter.com', + '.puter.com', + 'a.shop.puter.com', + 'shop.puter.com.example.test', + 'shopputer.com', + 'shop.other.com', + ]) { + expect(subdomainLabelOf(host, 'puter.com')).toBeUndefined(); + } + expect(subdomainLabelOf('shop.puter.com', '')).toBeUndefined(); + }); +}); + describe('addClaimedSubdomain', () => { const claim: SubdomainClaim = { authorize: () => true }; diff --git a/src/backend/core/http/middleware/hostRedirects.ts b/src/backend/core/http/middleware/hostRedirects.ts index 835d2889a..07ac0cb05 100644 --- a/src/backend/core/http/middleware/hostRedirects.ts +++ b/src/backend/core/http/middleware/hostRedirects.ts @@ -88,6 +88,18 @@ export type ClaimLookup = (name: string) => ResolvedClaim | undefined; const SUBDOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +/** The label `x` when `host` is exactly `x.`, ignoring case and port. */ +export const subdomainLabelOf = ( + host: string, + domain: string, +): string | undefined => { + const hostName = host.toLowerCase().split(':')[0]; + const base = domain.toLowerCase().split(':')[0]; + if (!base || !hostName.endsWith(`.${base}`)) return undefined; + const label = hostName.slice(0, -(base.length + 1)); + return label && !label.includes('.') ? label : undefined; +}; + /** * Record a claim on `.`. Throws on a malformed or uppercase * label, a subdomain core already handles, or a name that is already claimed. @@ -239,7 +251,8 @@ export const createUserSubdomainNotFound = ( // responder, body parsing, auth and every route, `subdomain: '*'` ones // included. One label deep only: `.x.` is not the claim. const claimed = - req.subdomains?.length === 1 && hostName === `${active}.${domain}` + req.subdomains?.length === 1 && + subdomainLabelOf(hostName, domain) === active ? lookupClaim(active) : undefined; if (claimed) { diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index f954636c2..5a0cc622d 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -19,6 +19,8 @@ import { actorOwnsRow, requireContextActor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; +import { subdomainLabelOf } from '../../core/http/middleware/hostRedirects.js'; +import { extensionStore } from '../../extensions.js'; import { DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, @@ -718,9 +720,10 @@ export class AppDriver extends PuterDriver { * loading from the GUI host would execute third-party code same-origin with * the desktop — a full sandbox escape. The API host is reserved for the * same reason, and the builtin sentinel host is rewritten by the GUI to - * `/builtin/…` (see BUILTIN_APPS_HOST above). Host comparison - * (rather than full origin) deliberately also catches scheme/port variants - * of these hosts. + * `/builtin/…` (see BUILTIN_APPS_HOST above). A subdomain an + * extension has claimed is a system host too. Host comparison (rather than + * full origin) deliberately also catches scheme/port variants of these + * hosts. */ #assertIndexUrlHostAllowed(indexUrl) { let hostname; @@ -752,7 +755,13 @@ export class AppDriver extends PuterDriver { } } - if (reserved.has(hostname)) { + const label = config.domain + ? subdomainLabelOf(hostname, config.domain) + : undefined; + if ( + reserved.has(hostname) || + (label !== undefined && extensionStore.claimedSubdomains.has(label)) + ) { throw new HttpError( 400, '`index_url` cannot point at a Puter system host', diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index 6297f674e..5c5ddf9a2 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -21,6 +21,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import type { Actor } from '../../core/actor.js'; import { runWithContext } from '../../core/context.js'; +import { extensionStore } from '../../extensions.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; import { decodeCursor } from '../../util/pagination.js'; @@ -195,6 +196,105 @@ describe('AppDriver.create', () => { }); }); + describe('with a claimed subdomain', () => { + const claimed = 'claimed-app-host'; + + beforeAll(() => { + extensionStore.claimedSubdomains.set(claimed, { + authorize: () => true, + }); + }); + + afterAll(() => { + extensionStore.claimedSubdomains.delete(claimed); + }); + + it.each([ + ['exactly', `https://${claimed}.puter.localhost/`], + [ + 'in uppercase', + `https://${claimed.toUpperCase()}.PUTER.localhost/`, + ], + ['with a port', `http://${claimed}.puter.localhost:4100/app.html`], + ])( + 'rejects an index_url on it %s with 400', + async (_label, index_url) => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.create({ + object: { + name: uniqueName('claimed-host'), + title: 't', + index_url, + }, + }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + message: /system host/, + }); + }, + ); + + it.each([ + ['exactly', `https://${claimed}.puter.localhost/`], + [ + 'in uppercase', + `https://${claimed.toUpperCase()}.PUTER.localhost/`, + ], + ['with a port', `http://${claimed}.puter.localhost:4100/app.html`], + ])( + 'rejects updating an index_url onto it %s with 400', + async (_label, index_url) => { + const { actor } = await makeUser(); + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('claimed-host-upd'), + title: 't', + index_url: uniqueIndexUrl(), + }, + }), + ); + await expect( + withActor(actor, () => + driver.update({ + uid: created.uid, + object: { index_url }, + }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + message: /system host/, + }); + }, + ); + + it.each([ + [ + 'an unclaimed sibling subdomain', + 'https://not-claimed-app-host.puter.localhost/', + ], + [ + 'a host nested under the claim', + `https://x.${claimed}.puter.localhost/`, + ], + ])('allows an index_url on %s', async (_label, index_url) => { + const { actor } = await makeUser(); + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('claimed-sibling'), + title: 't', + index_url, + }, + }), + ); + expect(created.index_url).toBe(index_url); + }); + }); + it('rejects a duplicate app name with 400', async () => { const a = await makeUser(); const b = await makeUser();