fix: PUT-1401 (#3504)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Daniel Salazar
2026-08-04 16:37:33 -07:00
committed by GitHub
parent 4a211b9092
commit 40c8d09f05
9 changed files with 416 additions and 77 deletions
@@ -242,7 +242,13 @@ export class AppController extends PuterController {
source: 'appsRoute',
args: req.query ?? {},
});
return { ...shaped, privateAccess };
return {
...shaped,
privateAccess:
shaped.privateAccess?.hasAccess === false
? shaped.privateAccess
: privateAccess,
};
}),
);
@@ -500,6 +500,73 @@ describe('AppController GET /apps/:name', () => {
expect((arr[0] as Record<string, unknown>).uid).toBe(app.uid);
expect(arr[1]).toBeNull();
});
// The driver is the authoritative producer of launch metadata: it runs
// the hosted-subdomain guard, which this route knows nothing about. The
// route's own entitlement re-check must never turn that denial into an
// affirmative verdict — the GUI launcher appends `puter.auth.token` to
// whatever `index_url` it is handed.
it('preserves the hosted-backing denial for the app owner', async () => {
const owner = await makeUser();
const sub = uniqueName('gone');
const row = await server.stores.subdomain.create({
userId: owner.userId,
subdomain: sub,
});
const app = await createApp(owner.actor, {
index_url: `https://${sub}.site.puter.localhost/`,
});
await server.stores.subdomain.deleteByUuid(
String((row as { uuid: string }).uuid),
{ userId: owner.userId },
);
const { res, captured } = makeRes();
await withActor(owner.actor, () =>
callRoute(
'get',
'/apps/:name',
makeReq({ params: { name: app.name }, actor: owner.actor }),
res,
),
);
const body = captured.body as Record<string, unknown>;
expect(body.privateAccess).toMatchObject({
hasAccess: false,
reason: 'hosted_backing_unavailable',
});
});
it('omits the stale index_url for a non-owner', async () => {
const owner = await makeUser();
const stranger = await makeUser();
const sub = uniqueName('gone');
const row = await server.stores.subdomain.create({
userId: owner.userId,
subdomain: sub,
});
const app = await createApp(owner.actor, {
index_url: `https://${sub}.site.puter.localhost/`,
});
await server.stores.subdomain.deleteByUuid(
String((row as { uuid: string }).uuid),
{ userId: owner.userId },
);
const { res, captured } = makeRes();
await withActor(stranger.actor, () =>
callRoute(
'get',
'/apps/:name',
makeReq({ params: { name: app.name }, actor: stranger.actor }),
res,
),
);
const body = captured.body as Record<string, unknown>;
expect(body.index_url).toBeUndefined();
expect(body.privateAccess).toMatchObject({ hasAccess: false });
});
});
// ── POST /query/app ─────────────────────────────────────────────────
+7 -5
View File
@@ -980,11 +980,13 @@ export class AppDriver extends PuterDriver {
// launch token to an origin the app owner no longer controls. Only
// set when not already denied so a private app's existing decision
// is preserved.
if (
hostedBackingUnavailable &&
result.privateAccess?.hasAccess !== false
) {
result.privateAccess = buildHostedBackingDenial();
if (hostedBackingUnavailable) {
if (result.privateAccess?.hasAccess !== false) {
result.privateAccess = buildHostedBackingDenial();
}
if (actor?.user?.id !== app.owner_user_id) {
delete result.index_url;
}
}
return result;
@@ -1520,6 +1520,48 @@ describe('AppDriver hosted-subdomain ownership check', () => {
expect(access?.reason).toBe('hosted_backing_unavailable');
});
it('withholds the stale index_url from everyone but the owner', async () => {
const owner = await makeUser();
const other = await makeUser();
const sub = uniqueName('stale');
const row = await server.stores.subdomain.create({
userId: owner.userId,
subdomain: sub,
});
const created = await withActor(owner.actor, () =>
driver.create({
object: {
name: uniqueName('app'),
title: 'Backed App',
index_url: hostedUrl(sub),
},
}),
);
await server.stores.subdomain.deleteByUuid(
String((row as { uuid: string }).uuid),
{ userId: owner.userId },
);
// The owner still sees it — dev center renders the URL in the app's
// edit form, and it's their row to repoint.
const asOwner = await withActor(owner.actor, () =>
driver.read({ uid: created.uid }),
);
expect(String(asOwner.index_url)).toContain(sub);
// Anyone else gets the denial without the URL it suppresses, so a
// consumer that reads `index_url` without reading the verdict still
// can't hand it to the launcher.
const asOther = await withActor(other.actor, () =>
driver.read({ uid: created.uid }),
);
expect(asOther.index_url).toBeUndefined();
expect(
(asOther.privateAccess as { hasAccess?: boolean }).hasAccess,
).toBe(false);
});
it('denies launch when the hosted subdomain was reclaimed by another user', async () => {
const owner = await makeUser();
const attacker = await makeUser();
@@ -601,7 +601,7 @@ describe('SubdomainDriver.delete', () => {
// an `index_url` match against the subdomain's host variants.
const createAppWithIndexUrl = async (
ownerUserId: number,
ownerUserId: number | null,
indexUrl: string,
opts: { isPrivate?: boolean } = {},
): Promise<{ id: number; uid: string }> => {
@@ -626,6 +626,104 @@ const createAppWithIndexUrl = async (
return row;
};
// ── Launch-origin takeover on re-registration ──────────────────────
//
// Deleting a hosted subdomain leaves the app row's `index_url` pointing
// at the freed name. The GUI launcher appends `puter.auth.token` to that
// URL, so whoever registers the name next would receive launch tokens
// for the original app. `create` therefore refuses a name another user's
// app still references — while leaving the app's own owner free to
// re-create it.
describe('SubdomainDriver.create launch-origin reservation', () => {
it("refuses a name another user's app still points at", async () => {
const victim = await makeUser();
const attacker = await makeUser();
const sub = uniqueSubdomain('freed');
await createAppWithIndexUrl(
victim.userId,
`http://${sub}.site.puter.localhost/`,
);
await expect(
withActor(attacker.actor, () =>
driver.create({
object: {
subdomain: sub,
root_dir: `/${attacker.actor.user!.username}/Public`,
},
}),
),
).rejects.toMatchObject({ statusCode: 409 });
expect(await server.stores.subdomain.getBySubdomain(sub)).toBeFalsy();
});
it('refuses a name an unowned origin-bootstrapped app points at', async () => {
const attacker = await makeUser();
const sub = uniqueSubdomain('orphan');
await createAppWithIndexUrl(
null,
`http://${sub}.site.puter.localhost`,
);
await expect(
withActor(attacker.actor, () =>
driver.create({
object: {
subdomain: sub,
root_dir: `/${attacker.actor.user!.username}/Public`,
},
}),
),
).rejects.toMatchObject({ statusCode: 409 });
});
it('lets the referencing app owner re-create the name', async () => {
const owner = await makeUser();
const sub = uniqueSubdomain('recreate');
await createAppWithIndexUrl(
owner.userId,
`http://${sub}.site.puter.localhost/`,
);
const created = (await withActor(owner.actor, () =>
driver.create({
object: {
subdomain: sub,
root_dir: `/${owner.actor.user!.username}/Public`,
},
}),
)) as Record<string, unknown>;
expect(created.subdomain).toBe(sub);
});
it('leaves names no app references claimable', async () => {
const other = await makeUser();
const claimant = await makeUser();
const sub = uniqueSubdomain('unrelated');
// Same owner, unrelated host: the reservation keys off the name,
// not off the existence of other people's apps.
await createAppWithIndexUrl(
other.userId,
'https://elsewhere.example.test/',
);
const created = (await withActor(claimant.actor, () =>
driver.create({
object: {
subdomain: sub,
root_dir: `/${claimant.actor.user!.username}/Public`,
},
}),
)) as Record<string, unknown>;
expect(created.subdomain).toBe(sub);
});
});
describe('SubdomainDriver associated_app derivation', () => {
it('ignores `associated_app_uid` on create and derives null when no app matches', async () => {
const { actor } = await makeUser();
@@ -706,10 +804,14 @@ describe('SubdomainDriver associated_app derivation', () => {
});
it("does not derive an `associated_app` for another user's app at the same host", async () => {
// The core IDOR: attacker (a) sets up a subdomain whose host
// matches the index_url of a victim's (b) private app. Even on
// exact index_url match, the ownership filter must reject the
// app from another user's account.
// The core IDOR: attacker (a) holds a subdomain whose host matches
// the index_url of a victim's (b) private app. Even on exact
// index_url match, the ownership filter must reject the app from
// another user's account.
//
// `create` now refuses that pairing outright (see the launch-origin
// reservation above), so the row is planted through the store — this
// is the legacy-data shape the derive filter still has to handle.
const a = await makeUser();
const b = await makeUser();
const sub = uniqueSubdomain('idor');
@@ -720,15 +822,15 @@ describe('SubdomainDriver associated_app derivation', () => {
{ isPrivate: true },
);
const created = (await withActor(a.actor, () =>
driver.create({
object: {
subdomain: sub,
root_dir: `/${a.actor.user!.username}/Public`,
},
}),
const row = await server.stores.subdomain.create({
userId: a.userId,
subdomain: sub,
});
const read = (await withActor(a.actor, () =>
driver.read({ uid: (row as { uuid: string }).uuid }),
)) as Record<string, unknown>;
expect(created.associated_app).toBeNull();
expect(read.associated_app).toBeNull();
});
});
@@ -31,6 +31,7 @@ import type { DriverRateLimitConfig } from '../meta.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
import { buildHostedSubdomainIndexUrlCandidates } from '../../util/hostedAppBacking.js';
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
import {
decodeCursor,
@@ -147,6 +148,31 @@ export class SubdomainDriver extends PuterDriver {
});
}
await this.services.fs.checkFSAccess(entry, actor);
// A name some other user's app still points at is not free either.
// Deleting a hosted subdomain leaves the app row's `index_url` intact,
// and the GUI launcher appends `puter.auth.token` to whatever URL it is
// given — so registering the freed name would hand that app's launch
// token to whoever claimed it. The app's own owner is exempt:
// re-creating their site restores their app rather than hijacking it.
// See `util/hostedAppBacking.ts` for the wider rule.
//
// Last check before the insert on purpose: `apps.index_url` is
// unindexed, so this scan only runs for a request that would otherwise
// have created the row, and it stays behind the same root_dir gate as
// the existing uniqueness answer.
const appHoldingName = await this.stores.app.findByIndexUrlCandidates(
buildHostedSubdomainIndexUrlCandidates(subdomain, this.config),
{ excludeOwnerUserId: actor.user.id },
);
if (appHoldingName) {
throw new HttpError(
409,
'A site with this subdomain already exists',
{ legacyCode: 'conflict' },
);
}
// `associated_app_id` is no longer accepted from clients. The
// "associated app" for a subdomain is derived at read time from
// `apps.owner_user_id = subdomain.user_id` + `index_url` match
@@ -604,37 +630,6 @@ export class SubdomainDriver extends PuterDriver {
const result = new Map<string, number>();
if (rows.length === 0) return result;
const normalize = (v: unknown): string | null => {
if (typeof v !== 'string') return null;
const trimmed = v.trim().toLowerCase().replace(/^\./, '');
return trimmed || null;
};
const stripPort = (v: string): string => v.split(':')[0] || v;
const hostingDomainsRaw = [
normalize(this.config.static_hosting_domain),
normalize(this.config.static_hosting_domain_alt),
normalize(this.config.private_app_hosting_domain),
normalize(this.config.private_app_hosting_domain_alt),
].filter((d): d is string => !!d);
const hostingDomains = [
...new Set([
...hostingDomainsRaw,
...hostingDomainsRaw.map(stripPort),
]),
];
if (hostingDomains.length === 0) return result;
const configuredProtocol =
typeof this.config.protocol === 'string'
? this.config.protocol.trim().replace(/:$/, '')
: '';
const protocols = [
...new Set(
[configuredProtocol, 'https', 'http'].filter((p) => !!p),
),
];
const userIdToRowMeta = new Map<
number,
Array<{ rowUuid: string; candidates: Set<string> }>
@@ -656,16 +651,10 @@ export class SubdomainDriver extends PuterDriver {
: '';
if (!subdomain || !Number.isFinite(userId) || !rowUuid) continue;
const candidates = new Set<string>();
for (const d of hostingDomains) {
const host = `${subdomain}.${d}`;
for (const p of protocols) {
const base = `${p}://${host}`;
candidates.add(base);
candidates.add(`${base}/`);
candidates.add(`${base}/index.html`);
}
}
const candidates = new Set<string>(
buildHostedSubdomainIndexUrlCandidates(subdomain, this.config),
);
if (candidates.size === 0) continue;
for (const c of candidates) allCandidates.add(c);
if (!userIdToRowMeta.has(userId)) {
+15 -3
View File
@@ -254,10 +254,18 @@ export class AppStore extends PuterStore {
* Find the oldest app whose `index_url` matches one of `candidates`. Used
* by the driver to detect duplicate puter-hosted index_url rows
* (origin-bootstrap apps + same-owner duplicates) so they can be merged on
* `create` / `update`. Returns the minimal row shape needed by the merge
* path; falsy when nothing matches.
* `create` / `update`, and by the subdomain driver to refuse a name another
* user's app still points at. Returns the minimal row shape needed by the
* merge path; falsy when nothing matches.
*
* `excludeOwnerUserId` skips rows owned by that user; unowned rows
* (origin-bootstrap apps) still match, since their launch origin is no less
* takeover-sensitive for having no owner.
*/
async findByIndexUrlCandidates(candidates, { excludeAppId } = {}) {
async findByIndexUrlCandidates(
candidates,
{ excludeAppId, excludeOwnerUserId } = {},
) {
if (!Array.isArray(candidates) || candidates.length === 0) return null;
const placeholders = candidates.map(() => '?').join(', ');
const params = [...candidates];
@@ -266,6 +274,10 @@ export class AppStore extends PuterStore {
sql += ' AND `id` != ?';
params.push(excludeAppId);
}
if (Number.isInteger(excludeOwnerUserId) && excludeOwnerUserId > 0) {
sql += ' AND (`owner_user_id` IS NULL OR `owner_user_id` != ?)';
params.push(excludeOwnerUserId);
}
sql += ' ORDER BY `timestamp` ASC, `id` ASC LIMIT 1';
const rows = await this.clients.db.read(sql, params);
return rows[0] ?? null;
+54
View File
@@ -20,6 +20,7 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildHostedBackingDenial,
buildHostedSubdomainIndexUrlCandidates,
extractPuterHostedSubdomain,
getPuterHostedDomains,
hostedIndexUrlBackingIsUnavailable,
@@ -67,6 +68,59 @@ describe('getPuterHostedDomains', () => {
});
});
describe('buildHostedSubdomainIndexUrlCandidates', () => {
it('covers every hosting domain, protocol and path variant', () => {
const candidates = buildHostedSubdomainIndexUrlCandidates('myapp', {
static_hosting_domain: 'puter.site',
protocol: 'https:',
});
expect(candidates).toEqual(
expect.arrayContaining([
'https://myapp.puter.site',
'https://myapp.puter.site/',
'https://myapp.puter.site/index.html',
'http://myapp.puter.site',
'http://myapp.puter.site/',
'http://myapp.puter.site/index.html',
]),
);
// Nothing beyond the three path shapes the app write path accepts.
expect(candidates).toHaveLength(6);
});
it('keeps both the ported and bare host for dev configs', () => {
const candidates = buildHostedSubdomainIndexUrlCandidates('dev', {
static_hosting_domain: 'site.puter.localhost:4100',
protocol: 'http',
});
expect(candidates).toContain('http://dev.site.puter.localhost:4100/');
expect(candidates).toContain('http://dev.site.puter.localhost/');
});
it('spans the alt and private hosting domains', () => {
const candidates = buildHostedSubdomainIndexUrlCandidates('x', CONFIG);
expect(candidates).toContain('https://x.puter.site/');
expect(candidates).toContain('https://x.site.puter.localhost/');
expect(candidates).toContain('https://x.private.puter.localhost/');
});
it('normalizes the name and refuses unusable input', () => {
expect(
buildHostedSubdomainIndexUrlCandidates(' MyApp ', {
static_hosting_domain: 'puter.site',
}),
).toContain('https://myapp.puter.site/');
expect(buildHostedSubdomainIndexUrlCandidates('', CONFIG)).toEqual([]);
expect(buildHostedSubdomainIndexUrlCandidates(null, CONFIG)).toEqual(
[],
);
expect(buildHostedSubdomainIndexUrlCandidates(42, CONFIG)).toEqual([]);
// No hosting domain configured — nothing can be hosted, so nothing
// is reserved.
expect(buildHostedSubdomainIndexUrlCandidates('x', {})).toEqual([]);
});
});
describe('extractPuterHostedSubdomain', () => {
it('extracts the label from a hosted url', () => {
expect(
+79 -14
View File
@@ -23,13 +23,19 @@ import type { PrivateLaunchDecision } from './privateLaunchAccess';
/**
* Launch-safety checks for puter-hosted `index_url`s.
*
* A hosted subdomain (`*.<hosting-domain>`) can be deleted by its owner and
* then re-registered by anyone else, but the app row keeps the stale URL
* nothing rewrites it on subdomain deletion. Any producer of launchable app
* metadata must therefore re-check the backing before handing an `index_url` to
* the GUI launcher, which appends `puter.auth.token` to it.
* A hosted subdomain (`*.<hosting-domain>`) can be deleted by its owner, but
* the app row keeps the stale URL nothing rewrites it on subdomain deletion.
* Any producer of launchable app metadata must therefore re-check the backing
* before handing an `index_url` to the GUI launcher, which appends
* `puter.auth.token` to it.
*
* This module is the single home for that check. `AppDriver` was the first
* The other half of the rule lives on the write side: `SubdomainDriver.create`
* refuses a name that some other user's app still points at
* (`buildHostedSubdomainIndexUrlCandidates`), so a freed name can't be
* re-registered under someone else's launch origin. Only the app's own owner
* may re-create it, which restores their app.
*
* This module is the single home for both checks. `AppDriver` was the first
* caller; `SuggestedAppsService` and `/get-launch-apps` build their own
* summaries and need the same guard, so keep the logic here rather than
* re-deriving it per producer.
@@ -42,6 +48,7 @@ interface HostedDomainConfig {
static_hosting_domain_alt?: unknown;
private_app_hosting_domain?: unknown;
private_app_hosting_domain_alt?: unknown;
protocol?: unknown;
}
interface AppBackingRow {
@@ -49,33 +56,91 @@ interface AppBackingRow {
owner_user_id?: unknown;
}
function normalizeConfiguredHostedDomain(domainValue: unknown): string | null {
function normalizeHostedDomainValue(domainValue: unknown): string | null {
if (typeof domainValue !== 'string') return null;
const normalizedDomain = domainValue
.trim()
.toLowerCase()
.replace(/^\./, '');
return normalizedDomain || null;
}
function normalizeConfiguredHostedDomain(domainValue: unknown): string | null {
const normalizedDomain = normalizeHostedDomainValue(domainValue);
if (!normalizedDomain) return null;
return normalizedDomain.split(':')[0] || null;
}
function configuredHostedDomainValues(
config: HostedDomainConfig | undefined | null,
): unknown[] {
const cfg = config ?? {};
return [
cfg.static_hosting_domain,
cfg.static_hosting_domain_alt,
cfg.private_app_hosting_domain,
cfg.private_app_hosting_domain_alt,
];
}
export function getPuterHostedDomains(
config: HostedDomainConfig | undefined | null,
): string[] {
const domains = new Set<string>();
const cfg = config ?? {};
for (const configuredDomain of [
cfg.static_hosting_domain,
cfg.static_hosting_domain_alt,
cfg.private_app_hosting_domain,
cfg.private_app_hosting_domain_alt,
]) {
for (const configuredDomain of configuredHostedDomainValues(config)) {
const normalized = normalizeConfiguredHostedDomain(configuredDomain);
if (normalized) domains.add(normalized);
}
return [...domains];
}
/**
* Every `index_url` string that resolves to `subdomain` on one of our hosting
* domains protocol, port and trailing-path variants included.
*
* `apps.index_url` is matched by exact string, so a caller asking "does any app
* still point at this name?" has to enumerate the same shapes the app write
* path accepts.
*/
export function buildHostedSubdomainIndexUrlCandidates(
subdomain: unknown,
config: HostedDomainConfig | undefined | null,
): string[] {
const name =
typeof subdomain === 'string' ? subdomain.trim().toLowerCase() : '';
if (!name) return [];
const hosts = new Set<string>();
for (const configuredDomain of configuredHostedDomainValues(config)) {
// Keep the configured form and a port-stripped one: dev configs carry
// a `:4100`-style port and stored index_urls exist in both shapes.
const withPort = normalizeHostedDomainValue(configuredDomain);
if (withPort) hosts.add(`${name}.${withPort}`);
const bare = normalizeConfiguredHostedDomain(configuredDomain);
if (bare) hosts.add(`${name}.${bare}`);
}
if (hosts.size === 0) return [];
const configuredProtocol =
typeof config?.protocol === 'string'
? config.protocol.trim().replace(/:$/, '')
: '';
const protocols = [
...new Set([configuredProtocol, 'https', 'http'].filter(Boolean)),
];
const candidates = new Set<string>();
for (const host of hosts) {
for (const protocol of protocols) {
const base = `${protocol}://${host}`;
candidates.add(base);
candidates.add(`${base}/`);
candidates.add(`${base}/index.html`);
}
}
return [...candidates];
}
/**
* Returns the subdomain label when `indexUrl` is hosted on one of our
* configured hosting domains, else null (a developer's own external domain, or