mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-06 22:13:04 +00:00
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:
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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user