mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-02 09:58:16 +00:00
Merge branch 'main' into juancastro/put-1944-teams-hardening
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user