fix(apps): refuse claimed subdomains as app index_url hosts (PUT-2110)

index_url may not point at a system host: the origin, the api host, or a
subdomain an extension has claimed. App create and update now apply the
same refusal to `<claimed>.<domain>` (any case, any port) that they apply
to the origin and api hosts.

The host-to-claim match is a new exported `subdomainLabelOf` in
hostRedirects, also used by the claim dispatch middleware, so both
decide the same way.
This commit is contained in:
Juan Castro committed 2026-10-19 10:52:34 -04:00
1 parent e5fd30ccf0
commit 37fb205a62
4 files changed
+154 -5

No files matched your search

@@ -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 };
@@ -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.<domain>`, 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 `<name>.<domain>`. 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: `<name>.x.<domain>` 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) {
+13 -4
View File
@@ -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
* `<gui origin>/builtin/…` (see BUILTIN_APPS_HOST above). Host comparison
* (rather than full origin) deliberately also catches scheme/port variants
* of these hosts.
* `<gui origin>/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',
+100
View File
@@ -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();