Merge branch 'main' into juancastro/put-1944-teams-hardening

This commit is contained in:
Juan Castro
2026-09-24 12:20:12 -04:00
12 changed files with 232 additions and 51 deletions
+4 -5
View File
@@ -850,11 +850,10 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -3120,6 +3120,39 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
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);
});
it('supports browser extension origins in handleGetUserAppToken', async () => {
// Random id: a pre-existing row for this origin would resolve through
// the canonical lookup and never exercise the bootstrap path.
@@ -5133,7 +5166,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();
});
@@ -5398,7 +5433,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 });
@@ -5426,7 +5464,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);
});
@@ -5972,10 +6012,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(),
),
);
@@ -6012,7 +6049,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(),
@@ -6857,7 +6897,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();
});
+11 -4
View File
@@ -4030,11 +4030,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
@@ -869,9 +869,7 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
@@ -1317,8 +1315,7 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1941,10 +1938,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
@@ -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'),
@@ -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;
+12
View File
@@ -32,6 +32,7 @@ import {
import { isUniqueViolation } from '../../util/dbError.js';
import {
buildHostedBackingDenial,
buildHostedSubdomainIndexUrlCandidates,
extractPuterHostedSubdomain,
hostedIndexUrlBackingIsUnavailable,
} from '../../util/hostedAppBacking.js';
@@ -1171,6 +1172,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.
@@ -1610,6 +1610,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(
@@ -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,<script>alert(1)</script>',
+42 -7
View File
@@ -784,11 +784,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 /
@@ -925,6 +925,39 @@ export class AuthService extends PuterService {
return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${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
@@ -983,8 +1016,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,
@@ -1045,8 +1078,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;
+10 -12
View File
@@ -762,18 +762,16 @@ export class TeamService extends PuterService {
id === null ? null : (users.get(id)?.username ?? null);
return {
items: page.items.map(
(row): MemberActivityEntry => ({
action: row.action,
reason: row.reason,
created_at: epochSeconds(row.created_at),
username: name(row.user_id_keep),
actor_username: name(row.actor_user_id),
// Only a sign-in carries these; the shape stays uniform.
ip: null,
user_agent: null,
}),
),
items: page.items.map((row): MemberActivityEntry => ({
action: row.action,
reason: row.reason,
created_at: epochSeconds(row.created_at),
username: name(row.user_id_keep),
actor_username: name(row.actor_user_id),
// Only a sign-in carries these; the shape stays uniform.
ip: null,
user_agent: null,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
};
}
+1 -4
View File
@@ -148,10 +148,7 @@ const RESERVED_HANDLES = new Set([
]);
export type HandleRejection =
| 'too_short'
| 'too_long'
| 'malformed'
| 'reserved';
'too_short' | 'too_long' | 'malformed' | 'reserved';
/** Trimmed and capped, so the same name is accepted on every engine. */
export const normalizeTeamName = (name: string): string => {