diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts
index 8e3be0f58..175889866 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.test.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
-const CURRENT_SCHEMA_VERSION = 89;
+const CURRENT_SCHEMA_VERSION = 90;
/**
* These suites migrate real files on disk. Idle they finish in well under a
diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts
index 383fce1a2..21ec4c26a 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.ts
@@ -128,6 +128,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[86, ['0091_icloud-dot-significance.sql']],
[87, ['0092_share-entry-owner.sql']],
[88, ['0093_fsentries-parent-uid-index.sql']],
+ [89, ['0094_deleted-apps.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_48.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_48.sql
new file mode 100644
index 000000000..7cf68058f
--- /dev/null
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_48.sql
@@ -0,0 +1,26 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Uid and owner of each deleted app. Mirrors SQLite migration 0094.
+--
+-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
+
+CREATE TABLE IF NOT EXISTS `deleted_apps` (
+ `uid` VARCHAR(40) NOT NULL,
+ `owner_user_id` INT UNSIGNED DEFAULT NULL,
+ PRIMARY KEY (`uid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_37.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_37.sql
new file mode 100644
index 000000000..b98f1b50e
--- /dev/null
+++ b/src/backend/clients/database/migrations/postgres/postgres_mig_37.sql
@@ -0,0 +1,26 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Uid and owner of each deleted app. Mirrors SQLite migration 0094 / MySQL
+-- mysql_mig_48.
+--
+-- Idempotent via IF NOT EXISTS.
+
+CREATE TABLE IF NOT EXISTS deleted_apps (
+ uid VARCHAR(40) PRIMARY KEY,
+ owner_user_id INTEGER DEFAULT NULL
+);
diff --git a/src/backend/clients/database/migrations/sqlite/0094_deleted-apps.sql b/src/backend/clients/database/migrations/sqlite/0094_deleted-apps.sql
new file mode 100644
index 000000000..a63377d7f
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0094_deleted-apps.sql
@@ -0,0 +1,25 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Uid and owner of each deleted app. An origin-derived uid comes back when the
+-- origin is used again; on a hosted subdomain now registered to someone else,
+-- AuthService moves on to a fresh uid so the deleted app's data stays put.
+
+CREATE TABLE IF NOT EXISTS `deleted_apps` (
+ "uid" TEXT PRIMARY KEY NOT NULL,
+ "owner_user_id" INTEGER DEFAULT NULL
+);
diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts
index c1072461e..873a24a7d 100644
--- a/src/backend/controllers/fs/FSController.ts
+++ b/src/backend/controllers/fs/FSController.ts
@@ -75,6 +75,7 @@ import {
type GuiItemEventName,
loadLegacyAssociatedApps,
replacedByMove,
+ resolveOrNotFound,
signEntryThumbnail,
} from './legacyFsHelpers.js';
import type {
@@ -1790,7 +1791,6 @@ export class FSController extends PuterController {
}
async #resolveEntryForRequest(source: Record) {
- const mod = await import('../../services/fs/resolveNode.js');
const username = Context.get('actor')?.user?.username;
const rawPath =
typeof source.path === 'string' ? source.path : undefined;
@@ -1810,15 +1810,8 @@ export class FSController extends PuterController {
? source.id
: undefined,
};
- const entry = await mod.resolveNode(this.stores.fsEntry, ref, {
- required: true,
- });
- if (!entry) {
- throw new HttpError(404, 'Entry not found', {
- legacyCode: 'not_found',
- });
- }
- return entry;
+ // The resolved path is the owner's real one, so a miss must not echo it.
+ return resolveOrNotFound(this.stores.fsEntry, ref);
}
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
@@ -2240,7 +2233,8 @@ export class FSController extends PuterController {
// the ActorUser type. Access via the escape hatch until a proper
// storage-quota mechanism is in place.
const actorUser = req.actor?.user as
- Record | undefined;
+ | Record
+ | undefined;
const candidates = [
this.#toStorageCapacityCandidate(actorUser?.free_storage),
diff --git a/src/backend/controllers/fs/legacyFsHelpers.ts b/src/backend/controllers/fs/legacyFsHelpers.ts
index 4d2a7c177..86c1f3768 100644
--- a/src/backend/controllers/fs/legacyFsHelpers.ts
+++ b/src/backend/controllers/fs/legacyFsHelpers.ts
@@ -129,7 +129,7 @@ const MAX_SELECTOR_DEPTH = 4;
* Resolve `ref`, reporting any miss as the same 404 an unreadable entry gets,
* so a lookup never echoes a resolved path or tells missing from hidden.
*/
-async function resolveOrNotFound(
+export async function resolveOrNotFound(
fsEntryStore: FSEntryStore,
ref: NodeRef,
): Promise {
diff --git a/src/backend/controllers/fs/sharedFolderWrites.http.test.ts b/src/backend/controllers/fs/sharedFolderWrites.http.test.ts
index 63b3bc0a9..de4c1f037 100644
--- a/src/backend/controllers/fs/sharedFolderWrites.http.test.ts
+++ b/src/backend/controllers/fs/sharedFolderWrites.http.test.ts
@@ -194,4 +194,57 @@ describe('writes inside a folder shared with write', () => {
(moved.json!.moved as { path: string }).path,
).toBe(`${s.maskedDir}/renamed.txt`);
}, 180_000);
+
+ // The recipient only knows the masked path, so refusals name entries the
+ // way the recipient addressed them.
+ it('keeps the owner’s real path out of error messages', async () => {
+ const s = await shareAFolder();
+ await post('/touch', s.owner.token, {
+ path: `${s.realDir}/other.txt`,
+ });
+
+ const cases = [
+ {
+ res: await post('/fs/stat', s.recipient.token, {
+ path: `${s.maskedDir}/missing.txt`,
+ }),
+ status: 404,
+ message: 'Subject does not exist',
+ },
+ {
+ res: await post('/fs/mkdir', s.recipient.token, {
+ path: `${s.maskedDir}/note.txt`,
+ }),
+ status: 409,
+ message: 'An entry named `note.txt` already exists',
+ },
+ {
+ res: await post('/fs/mkdir', s.recipient.token, {
+ path: `${s.maskedDir}/missing/child`,
+ }),
+ status: 404,
+ message: 'Parent path does not exist: missing',
+ },
+ {
+ res: await post('/fs/rename', s.recipient.token, {
+ uid: s.noteUid,
+ new_name: 'other.txt',
+ }),
+ status: 409,
+ message: 'An entry named `other.txt` already exists',
+ },
+ {
+ res: await post('/mkdir', s.recipient.token, {
+ path: `${s.maskedDir}/note.txt`,
+ }),
+ status: 409,
+ message: 'An entry named `note.txt` already exists',
+ },
+ ];
+ for (const { res, status, message } of cases) {
+ expect(res.status).toBe(status);
+ expect(res.json!.message).toBe(message);
+ expect(JSON.stringify(res.json)).not.toContain(s.realDir);
+ }
+ }, 180_000);
});
diff --git a/src/backend/controllers/share/ShareController.http.test.ts b/src/backend/controllers/share/ShareController.http.test.ts
index 047aacd91..d89b9bd6d 100644
--- a/src/backend/controllers/share/ShareController.http.test.ts
+++ b/src/backend/controllers/share/ShareController.http.test.ts
@@ -341,6 +341,61 @@ describe('share endpoints over HTTP', () => {
expect(replayed.items).toEqual([]);
});
+ it('drops what a folder delegate re-shared once the item leaves the folder', async () => {
+ const owner = await makeUser();
+ const delegate = await makeUser();
+ const holder = await makeUser();
+
+ const dir = (await (
+ await post('/mkdir', owner.token, {
+ path: `/${owner.username}/Proj`,
+ })
+ ).json()) as { uid: string };
+ await post('/touch', owner.token, {
+ path: `/${owner.username}/Proj/plan.txt`,
+ });
+ const file = (await (
+ await post('/stat', owner.token, {
+ path: `/${owner.username}/Proj/plan.txt`,
+ })
+ ).json()) as { uid: string };
+
+ await post('/share', owner.token, {
+ recipients: [delegate.username],
+ items: [{ uid: dir.uid }],
+ mode: 'manage',
+ });
+ await post('/share', delegate.token, {
+ recipients: [holder.username],
+ items: [{ uid: file.uid }],
+ mode: 'read',
+ });
+ const listedTo = async (token: string) =>
+ (
+ (await (
+ await get('/share/shared-by-me', token, {})
+ ).json()) as { items: Array<{ uid_entry: string }> }
+ ).items.map((i) => i.uid_entry);
+ expect(await listedTo(delegate.token)).toContain(file.uid);
+
+ const moved = await post('/move', owner.token, {
+ source: file.uid,
+ destination: `/${owner.username}`,
+ });
+ expect(moved.status).toBe(200);
+ // A move doesn't bump readings; this stands in for the scan-cache TTL.
+ const delegateRow = await env.server.stores.user.getByUsername(
+ delegate.username,
+ );
+ await env.server.stores.permission.bumpCacheGeneration(
+ `user:${delegateRow!.uuid}`,
+ );
+
+ expect(await listedTo(delegate.token)).not.toContain(file.uid);
+ // The holder's grant still stands, so its owner still sees it.
+ expect(await listedTo(owner.token)).toContain(file.uid);
+ });
+
// A caller who believes they filtered must not silently receive
// everything: a duplicated param arrives as an array, and an empty
// string names nothing.
diff --git a/src/backend/controllers/wisp/WispController.test.ts b/src/backend/controllers/wisp/WispController.test.ts
index 1f84d1a7e..d37e2da19 100644
--- a/src/backend/controllers/wisp/WispController.test.ts
+++ b/src/backend/controllers/wisp/WispController.test.ts
@@ -4,7 +4,7 @@ import type { Request, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
-import { setupTestServer } from '../../testUtil.js';
+import { createTestUser, setupTestServer } from '../../testUtil.js';
import type { WispController } from './WispController.js';
let server: PuterServer;
@@ -147,26 +147,114 @@ describe('WispController', () => {
});
describe('verify', () => {
- it('verifies a valid authenticated-user token', async () => {
- const { res: createRes, captured: createCaptured } = makeRes();
- await createHandler(
- makeReq({
- actor: {
- user: {
- uuid: '00000000-0000-0000-0000-000000000001',
- },
- },
- }),
- createRes,
- );
- const token = (createCaptured.body as { token: string }).token;
+ const relayTokenFor = async (actor: unknown) => {
+ const { res, captured } = makeRes();
+ await createHandler(makeReq({ actor }), res);
+ return (captured.body as { token: string }).token;
+ };
+
+ const verifyStatus = async (token: string) => {
+ const { res, captured } = makeRes();
+ try {
+ await verifyHandler(makeReq({ body: { token } }), res);
+ return captured.statusCode;
+ } catch (e) {
+ return (e as { statusCode: number }).statusCode;
+ }
+ };
+
+ const freshUser = async () => {
+ const username = `wisp_${Math.random().toString(36).slice(2, 10)}`;
+ const creds = await createTestUser(server, {
+ username,
+ password: 'wisp-test-password',
+ });
+ const row = (await server.stores.user.getByUsername(username))!;
+ const actorFor = async (token: string) =>
+ (await server.services.auth.authenticateFromToken(token))!;
+ return {
+ row,
+ sessionActor: await actorFor(creds.token),
+ apiTokenActor: await actorFor(creds.apiToken),
+ apiToken: creds.apiToken,
+ };
+ };
+
+ it('verifies a token minted by a live session', async () => {
+ const { sessionActor } = await freshUser();
+ const token = await relayTokenFor(sessionActor);
const { res, captured } = makeRes();
await verifyHandler(makeReq({ body: { token } }), res);
expect(captured.statusCode).toBe(200);
- const body = captured.body as { allow: boolean };
- expect(body.allow).toBe(true);
+ expect((captured.body as { allow: boolean }).allow).toBe(true);
+ });
+
+ it('rejects a token once the account is suspended', async () => {
+ const { row, sessionActor } = await freshUser();
+ const token = await relayTokenFor(sessionActor);
+
+ await server.stores.user.update(row.id, { suspended: 1 });
+
+ expect(await verifyStatus(token)).toBe(403);
+ });
+
+ it('rejects a token once the account is pending verification', async () => {
+ const { row, sessionActor } = await freshUser();
+ const token = await relayTokenFor(sessionActor);
+
+ await server.stores.user.update(row.id, {
+ requires_email_confirmation: 1,
+ email_confirmed: 0,
+ });
+
+ expect(await verifyStatus(token)).toBe(403);
+ });
+
+ it('rejects a token whose user no longer exists', async () => {
+ const token = await relayTokenFor({
+ user: { uuid: '00000000-0000-0000-0000-000000000001' },
+ });
+
+ expect(await verifyStatus(token)).toBe(403);
+ });
+
+ it('rejects a token once its session is revoked', async () => {
+ const { sessionActor } = await freshUser();
+ const token = await relayTokenFor(sessionActor);
+ expect(await verifyStatus(token)).toBe(200);
+
+ await server.services.auth.revokeSession(sessionActor.session!.uid);
+
+ expect(await verifyStatus(token)).toBe(403);
+ });
+
+ it('binds a token minted by an access token to that token', async () => {
+ const { sessionActor, apiTokenActor, apiToken } = await freshUser();
+ const token = await relayTokenFor(apiTokenActor);
+ expect(await verifyStatus(token)).toBe(200);
+
+ await server.services.auth.revokeAccessToken(
+ sessionActor,
+ apiToken,
+ );
+
+ expect(await verifyStatus(token)).toBe(403);
+ });
+
+ it('applies the account checks to a token with no session claim', async () => {
+ const { row } = await freshUser();
+ const token = server.services.token.sign(
+ 'wisp',
+ { $: 'token:wisp', $v: '0.0.0', user_uid: row.uuid },
+ { expiresIn: '1d' },
+ );
+ expect(await verifyStatus(token)).toBe(200);
+
+ await server.stores.user.update(row.id, { suspended: 1 });
+
+ expect(await verifyStatus(token)).toBe(403);
});
it('verifies a valid guest token', async () => {
diff --git a/src/backend/controllers/wisp/WispController.ts b/src/backend/controllers/wisp/WispController.ts
index d9c7751d4..8d97e35da 100644
--- a/src/backend/controllers/wisp/WispController.ts
+++ b/src/backend/controllers/wisp/WispController.ts
@@ -18,7 +18,12 @@
*/
import type { Request, Response } from 'express';
+import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
+import {
+ assertNotSuspended,
+ assertVerifiedAccount,
+} from '../../core/http/middleware/gates.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import {
@@ -71,12 +76,14 @@ export class WispController extends PuterController {
const wispCfg = this.#wispConfig();
if (actor?.user?.uuid) {
+ const sessionUid = await this.#sessionUidFor(actor);
const token = this.services.token.sign(
'wisp',
{
$: 'token:wisp',
$v: '0.0.0',
user_uid: actor.user.uuid,
+ ...(sessionUid ? { session_uid: sessionUid } : {}),
},
{ expiresIn: '1d' },
);
@@ -120,13 +127,10 @@ export class WispController extends PuterController {
});
}
- // Build policy event — extensions can deny via extension.on('wisp.get-policy')
const isGuest = Boolean(decoded.guest);
- let user: Record | null = null;
- if (!isGuest && decoded.user_uid) {
- user = await this.stores.user.getByUuid(String(decoded.user_uid));
- }
+ const user = isGuest ? null : await this.#liveUserFor(decoded);
+ // Build policy event — extensions can deny via extension.on('wisp.get-policy')
const event: Record = {
allow: true,
policy: { allow: true },
@@ -145,6 +149,43 @@ export class WispController extends PuterController {
res.json(event.policy);
};
+ /**
+ * The session a relay token lives no longer than. An access token's is its
+ * own session row; the actor doesn't carry it.
+ */
+ async #sessionUidFor(actor: Actor): Promise {
+ if (actor.session?.uid) return actor.session.uid;
+ if (!actor.accessToken) return undefined;
+ const row = await this.stores.session.findActiveByAccessTokenUid(
+ actor.accessToken.uid,
+ );
+ return row?.uuid;
+ }
+
+ /**
+ * The token's user, held to the same account gates as minting. A token
+ * without `session_uid` (minted before the claim existed, or by a legacy
+ * access token with no session row) gets the account checks only.
+ */
+ async #liveUserFor(
+ decoded: Record,
+ ): Promise> {
+ const forbidden = () =>
+ new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
+ if (!decoded.user_uid) throw forbidden();
+ const user = await this.stores.user.getByUuid(String(decoded.user_uid));
+ if (!user) throw forbidden();
+ assertNotSuspended(user);
+ assertVerifiedAccount(user);
+ if (
+ decoded.session_uid &&
+ !(await this.stores.session.getByUuid(String(decoded.session_uid)))
+ ) {
+ throw forbidden();
+ }
+ return user;
+ }
+
#wispConfig(): NonNullable {
return this.config.wisp ?? {};
}
diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js
index 255d421c8..f954636c2 100644
--- a/src/backend/drivers/apps/AppDriver.js
+++ b/src/backend/drivers/apps/AppDriver.js
@@ -441,6 +441,22 @@ export class AppDriver extends PuterDriver {
indexUrl: fields.index_url,
excludeAppId: app.id,
});
+ if (
+ app.is_private &&
+ (await this.services.auth.originHeldByOtherUser(
+ fields.index_url,
+ app.owner_user_id,
+ ))
+ ) {
+ throw new HttpError(
+ 400,
+ "A private app can't use an index_url whose origin belongs to another user's app or site",
+ {
+ legacyCode: 'app_index_url_already_in_use',
+ fields: { index_url: fields.index_url },
+ },
+ );
+ }
}
// Name conflict check (only if name is changing)
diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts
index 41a18f340..6297f674e 100644
--- a/src/backend/drivers/apps/AppDriver.test.ts
+++ b/src/backend/drivers/apps/AppDriver.test.ts
@@ -1295,6 +1295,78 @@ describe('AppDriver.update additional branches', () => {
expect.arrayContaining(['md', 'csv']),
);
});
+
+ describe('a private app moving its index_url', () => {
+ const createApp = async (actor: Actor, indexUrl: string) =>
+ withActor(actor, () =>
+ driver.create({
+ object: {
+ name: uniqueName('pv'),
+ title: 't',
+ index_url: indexUrl,
+ },
+ }),
+ );
+ const makePrivate = async (app: Record) => {
+ await server.clients.db.write(
+ 'UPDATE `apps` SET `is_private` = 1 WHERE `uid` = ?',
+ [app.uid],
+ );
+ await server.stores.app.invalidateByUid(app.uid as string);
+ };
+
+ it('refuses an origin another user’s app already resolves to', async () => {
+ const other = await makeUser();
+ const target = uniqueIndexUrl();
+ const theirs = await createApp(other.actor, target);
+ const { actor } = await makeUser();
+ const mine = await createApp(actor, uniqueIndexUrl());
+ await makePrivate(mine);
+
+ await expect(
+ withActor(actor, () =>
+ driver.update({
+ uid: mine.uid,
+ object: { index_url: target },
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ expect(
+ await server.services.auth.appUidFromOrigin(
+ new URL(target).origin,
+ ),
+ ).toBe(theirs.uid);
+ });
+
+ it('allows an unclaimed origin, a path below another user’s origin, and a public app', async () => {
+ const other = await makeUser();
+ const target = uniqueIndexUrl();
+ await createApp(other.actor, target);
+ const { actor } = await makeUser();
+ const mine = await createApp(actor, uniqueIndexUrl());
+ await makePrivate(mine);
+
+ const fresh = uniqueIndexUrl();
+ const moved = await withActor(actor, () =>
+ driver.update({ uid: mine.uid, object: { index_url: fresh } }),
+ );
+ expect(moved.index_url).toBe(fresh);
+ const below = `${target}app/`;
+ const nested = await withActor(actor, () =>
+ driver.update({ uid: mine.uid, object: { index_url: below } }),
+ );
+ expect(nested.index_url).toBe(below);
+
+ const publicApp = await createApp(actor, uniqueIndexUrl());
+ const copied = await withActor(actor, () =>
+ driver.update({
+ uid: publicApp.uid,
+ object: { index_url: target },
+ }),
+ );
+ expect(copied.index_url).toBe(target);
+ });
+ });
});
// ── read: additional branches ──────────────────────────────────────
diff --git a/src/backend/drivers/kv/KVStoreDriver.test.ts b/src/backend/drivers/kv/KVStoreDriver.test.ts
index f2570992a..ba6069f97 100644
--- a/src/backend/drivers/kv/KVStoreDriver.test.ts
+++ b/src/backend/drivers/kv/KVStoreDriver.test.ts
@@ -1368,6 +1368,75 @@ describe('KVStoreDriver', () => {
);
});
+ it('lets the expire op set an expiry without the delete class', async () => {
+ const { owner, calendar, contacts, calendarActor } = await setup();
+ await grant(
+ owner,
+ calendar.uid,
+ appDataPermission(contacts.uid, 'kv', 'expire'),
+ );
+ expect(
+ await crossApp(calendarActor, contacts.uid, (optConfig) =>
+ target.expire({ key: 'entry', ttl: 60, optConfig }),
+ ),
+ ).toBe(true);
+ });
+
+ it('returns no value from remove without read access', async () => {
+ const { owner, calendar, contacts, calendarActor, contactsActor } =
+ await setup();
+ await inCtx(
+ () =>
+ target.set({
+ key: 'doc',
+ value: { token: 'secret', x: 1 },
+ }),
+ contactsActor,
+ );
+ await grant(
+ owner,
+ calendar.uid,
+ appDataPermission(contacts.uid, 'kv', 'remove'),
+ );
+ const remove = (paths: string[]) =>
+ crossApp(calendarActor, contacts.uid, (optConfig) =>
+ target.remove({ key: 'doc', paths, optConfig }),
+ );
+
+ // A missing top-level path and a missing nested one both remove
+ // nothing; neither may hand back the stored value.
+ expect(await remove(['nope'])).toBeNull();
+ expect(await remove(['also.missing'])).toBeNull();
+ expect(await remove(['x'])).toBeNull();
+ expect(
+ await inCtx(() => target.get({ key: 'doc' }), contactsActor),
+ ).toEqual({ token: 'secret' });
+ });
+
+ it('returns the updated value from remove with read access too', async () => {
+ const { owner, calendar, contacts, calendarActor, contactsActor } =
+ await setup();
+ await inCtx(
+ () => target.set({ key: 'doc', value: { a: 1, b: 2 } }),
+ contactsActor,
+ );
+ await grant(
+ owner,
+ calendar.uid,
+ appDataPermission(contacts.uid, 'kv', 'remove'),
+ );
+ await grant(
+ owner,
+ calendar.uid,
+ appDataPermission(contacts.uid, 'kv', 'get'),
+ );
+ expect(
+ await crossApp(calendarActor, contacts.uid, (optConfig) =>
+ target.remove({ key: 'doc', paths: ['a'], optConfig }),
+ ),
+ ).toEqual({ b: 2 });
+ });
+
// -- Compatibility -------------------------------------------------
it('does not consult permissions for an own-namespace call', async () => {
diff --git a/src/backend/drivers/kv/KVStoreDriver.ts b/src/backend/drivers/kv/KVStoreDriver.ts
index d8f744e1e..c1f6c1cb1 100644
--- a/src/backend/drivers/kv/KVStoreDriver.ts
+++ b/src/backend/drivers/kv/KVStoreDriver.ts
@@ -240,8 +240,10 @@ export class KVStoreDriver extends PuterDriver {
});
}
+ // `expire` takes its TTL as `ttl`, and its own op is already a deletion.
const raw = args as Record;
if (
+ op !== 'expire' &&
APP_DATA_KV_TTL_PARAMS.some(
(p) => raw[p] !== undefined && raw[p] !== null,
)
@@ -642,6 +644,16 @@ export class KVStoreDriver extends PuterDriver {
opts,
);
this.#meter(opts.actor, usage);
+ // The result is the entry's value, so a delete-only grant gets none.
+ if (
+ opts.namespaceAppUuid &&
+ !(await this.services.permission.check(
+ opts.actor!,
+ appDataPermission(opts.namespaceAppUuid, 'kv', 'get'),
+ ))
+ ) {
+ return null;
+ }
return res;
}
}
diff --git a/src/backend/drivers/util/fileInput.ts b/src/backend/drivers/util/fileInput.ts
index 730bd1dac..a809823f6 100644
--- a/src/backend/drivers/util/fileInput.ts
+++ b/src/backend/drivers/util/fileInput.ts
@@ -59,6 +59,8 @@ export interface LoadedFile {
bucketRegion: string | null;
size: number | null;
sqlId: number | null; // null in case of base64 URL or a future dynamodb FS.
+ /** The file's owner, who is not necessarily the actor that read it. */
+ userId: number;
} | null;
}
@@ -75,7 +77,8 @@ export interface ResolvedFileInput {
* uuid? }`.
*/
export type FileInputRef =
- string | { path?: string; uid?: string; uuid?: string };
+ | string
+ | { path?: string; uid?: string; uuid?: string };
export interface OpenedFileInput {
body: Readable;
@@ -278,6 +281,7 @@ const fsEntryInfo = (entry: FSEntry): NonNullable => ({
bucketRegion: entry.bucketRegion,
size: entry.size,
sqlId: entry.id,
+ userId: entry.userId,
});
/** {@link openFileInputStream} for an entry that is already resolved. */
diff --git a/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts b/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts
index 48ad819e8..6ddc57c80 100644
--- a/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts
+++ b/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts
@@ -422,6 +422,102 @@ describe('WorkerDriver.create with a configured deploy backend', () => {
expect(putCalls()).toHaveLength(0);
});
+ // The file's owner keeps editing rights at any share level, and later
+ // redeploys read the file as the worker's owner.
+ it.each(['read', 'write', 'manage'] as const)(
+ 'refuses a source file shared with the actor at %s',
+ async (mode) => {
+ const owner = await makeUser();
+ const holder = await makeUser();
+ const path = `/${owner.user.username}/worker.js`;
+ const entry = await writeSource(
+ owner.actor,
+ owner.user.id,
+ path,
+ 'v1',
+ );
+ await server.services.acl.setUserUser(
+ owner.actor,
+ holder.actor,
+ {
+ path: entry.path,
+ resolveAncestors: () =>
+ server.services.fs.getAncestorChain(entry.path),
+ },
+ mode,
+ );
+ const name = `borrowed-${mode}-${holder.user.username}`;
+
+ await expect(
+ inCtx(holder.actor, () =>
+ target.create({
+ appId: '',
+ workerName: name,
+ filePath: path,
+ }),
+ ),
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ legacyCode: 'forbidden',
+ });
+ expect(putCalls()).toHaveLength(0);
+ expect(
+ await server.stores.subdomain.getBySubdomain(
+ `workers.puter.${name}`,
+ ),
+ ).toBeNull();
+ expect(
+ await server.stores.session.getWorker(holder.user.id, {
+ workerName: name,
+ }),
+ ).toBeNull();
+ },
+ );
+
+ it('refuses repointing an existing worker at another user’s file', async () => {
+ const { user, actor } = await makeUser();
+ const other = await makeUser();
+ const own = `/${user.username}/worker.js`;
+ const ownEntry = await writeSource(actor, user.id, own, 'v1');
+ const foreign = `/${other.user.username}/worker.js`;
+ const foreignEntry = await writeSource(
+ other.actor,
+ other.user.id,
+ foreign,
+ 'v2',
+ );
+ await server.services.acl.setUserUser(
+ other.actor,
+ actor,
+ {
+ path: foreignEntry.path,
+ resolveAncestors: () =>
+ server.services.fs.getAncestorChain(foreignEntry.path),
+ },
+ 'read',
+ );
+ const name = `repoint-${user.username}`;
+ await inCtx(actor, () =>
+ target.create({ appId: '', workerName: name, filePath: own }),
+ );
+
+ await expect(
+ inCtx(actor, () =>
+ target.create({
+ appId: '',
+ workerName: name,
+ filePath: foreign,
+ [INTERNAL_ADMISSION_BYPASS]: true,
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ expect(putCalls()).toHaveLength(1);
+ const row = await server.stores.subdomain.getBySubdomain(
+ `workers.puter.${name}`,
+ );
+ expect(Number(row!.root_dir_id)).toBe(ownEntry.id);
+ });
+
it('surfaces an edge deploy failure with stack lines rebased past the preamble', async () => {
const { user, actor } = await makeUser();
const path = `/${user.username}/worker.js`;
@@ -472,7 +568,8 @@ describe('WorkerDriver.create with a configured deploy backend', () => {
target.create({
appId: '',
workerName: 'ghost-worker',
- filePath: '/ghost/worker.js',
+ // Inline source: a path would 404 before the user lookup.
+ filePath: 'data:application/javascript,export default {}',
}),
),
).rejects.toMatchObject({
diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts
index 361ba7bf3..eaca67413 100644
--- a/src/backend/drivers/workers/WorkerDriver.ts
+++ b/src/backend/drivers/workers/WorkerDriver.ts
@@ -407,6 +407,36 @@ export class WorkerDriver extends PuterDriver {
);
}
+ // Read source file. loadFileInput runs the read-ACL check internally
+ // before pulling bytes from S3. Generated source skips all of it:
+ // there is no file, so there is nothing to authorize a read of.
+ const loaded =
+ deployTarget?.source === undefined
+ ? await loadFileInput(
+ {
+ fsEntry: this.stores.fsEntry,
+ s3Object: this.stores.s3Object,
+ },
+ this.services.fs,
+ actor,
+ filePath,
+ { maxBytes: MAX_SOURCE_SIZE },
+ )
+ : null;
+ // A worker is redeployed from its file as the worker's owner, so a
+ // file someone else owns would let them swap the code it runs.
+ if (
+ !deployTarget &&
+ loaded?.fsEntry &&
+ loaded.fsEntry.userId !== actor.user.id
+ ) {
+ throw new HttpError(
+ 403,
+ 'A worker can only be deployed from a file you own',
+ { legacyCode: 'forbidden' },
+ );
+ }
+
// If tied to an app, get an app-scoped worker token. Worker tokens
// use `kind='worker'` so they don't collide with any interactive
// `kind='app'` session for the same (user, app); the long expiry
@@ -449,22 +479,6 @@ export class WorkerDriver extends PuterDriver {
authorization = session.token;
}
- // Read source file. loadFileInput runs the read-ACL check internally
- // before pulling bytes from S3. Generated source skips all of it:
- // there is no file, so there is nothing to authorize a read of.
- const loaded =
- deployTarget?.source === undefined
- ? await loadFileInput(
- {
- fsEntry: this.stores.fsEntry,
- s3Object: this.stores.s3Object,
- },
- this.services.fs,
- actor,
- filePath,
- { maxBytes: MAX_SOURCE_SIZE },
- )
- : null;
const sourceCode =
deployTarget?.source ?? loaded!.buffer.toString('utf-8');
diff --git a/src/backend/services/acl/ACLService.test.ts b/src/backend/services/acl/ACLService.test.ts
index d550ba5c4..506837035 100644
--- a/src/backend/services/acl/ACLService.test.ts
+++ b/src/backend/services/acl/ACLService.test.ts
@@ -92,6 +92,10 @@ function makeService() {
// Default: no node on the chain is open to anyone with the link.
listAnyoneReaching: vi.fn().mockResolvedValue([]),
},
+ app: {
+ // Default: no app row behind any AppData folder.
+ getByUids: vi.fn().mockResolvedValue(new Map()),
+ },
};
const services = {
permission: {
@@ -380,6 +384,56 @@ describe('ACLService.check — app-under-user', () => {
).toBe(true);
});
+ it("is refused an opted-out app's AppData whatever it was granted", async () => {
+ const { service, stores, services } = makeService();
+ services.permission.scan.mockResolvedValue([{ $: 'option', key: 'k' }]);
+ const closed = `app-${uuidv4()}`;
+ stores.app.getByUids.mockImplementation(async (uids: string[]) =>
+ uids.includes(closed)
+ ? new Map([
+ [
+ closed,
+ { uid: closed, metadata: { share_app_data: false } },
+ ],
+ ])
+ : new Map(),
+ );
+ // Asked in another case; the stored chain names the real folder.
+ const asked: ResourceDescriptor = {
+ path: `/issuer/appdata/${closed.toUpperCase()}/state.json`,
+ resolveAncestors: async () => [
+ {
+ uid: 'uid:file',
+ path: `/issuer/AppData/${closed}/state.json`,
+ },
+ { uid: 'uid:root', path: `/issuer/AppData/${closed}` },
+ { uid: 'uid:appdata', path: '/issuer/AppData' },
+ { uid: 'uid:home', path: '/issuer' },
+ ],
+ };
+ expect(await service.check(appActor('issuer'), asked, 'read')).toBe(
+ false,
+ );
+ expect(stores.app.getByUids).toHaveBeenCalledWith(
+ expect.arrayContaining([closed]),
+ );
+ });
+
+ it('looks up no app for an AppData folder not named like one', async () => {
+ const { service, stores, services } = makeService();
+ services.permission.scan.mockResolvedValue([{ $: 'option', key: 'k' }]);
+ for (const name of ['日本', 'notes', 'app-2']) {
+ expect(
+ await service.check(
+ appActor('issuer'),
+ resource(`/issuer/AppData/${name}/x`),
+ 'read',
+ ),
+ ).toBe(true);
+ }
+ expect(stores.app.getByUids).not.toHaveBeenCalled();
+ });
+
it("does not get its user's home short-circuit", async () => {
const { service, services } = makeService();
// The app's user owns /issuer, so the recursive user check passes,
@@ -1073,7 +1127,10 @@ describe('ACLService.statUserUser / setUserUser (integration)', () => {
});
describe('app-under-user on shared paths', () => {
- const makeApp = async (ownerUserId: number) =>
+ const makeApp = async (
+ ownerUserId: number,
+ fields: Record = {},
+ ) =>
(
server.stores.app.create as unknown as (
fields: Record,
@@ -1084,6 +1141,7 @@ describe('ACLService.statUserUser / setUserUser (integration)', () => {
name: `acl-app-${uuidv4()}`,
title: 'ACL app',
index_url: `https://acl-${uuidv4()}.test/`,
+ ...fields,
},
{ ownerUserId },
);
@@ -1198,6 +1256,71 @@ describe('ACLService.statUserUser / setUserUser (integration)', () => {
await acl.check(asApp(holder, mine), appDataOf(theirs.uid), 'write'),
).toBe(false);
});
+
+ describe("another app's AppData", () => {
+ /** `//AppData//inner`, as the app would leave it. */
+ const seedAppData = async (user: Actor, appUid: string) => {
+ const root = `/${user.user.username}/AppData/${appUid}`;
+ await server.services.fs.mkdir(user.user.id!, {
+ path: `${root}/inner`,
+ createMissingParents: true,
+ } as never);
+ return root;
+ };
+
+ const grantTo = (
+ user: Actor,
+ app: { uid: string },
+ permission: string,
+ ) =>
+ runWithContext({ actor: user }, () =>
+ server.services.permission.grantUserAppPermission(
+ user,
+ app.uid,
+ permission,
+ ),
+ );
+
+ it('stays closed to every grant once that app opts out of sharing', async () => {
+ const user = await makeUser();
+ const closed = await makeApp(user.user.id!, {
+ metadata: JSON.stringify({ share_app_data: false }),
+ });
+ const root = await seedAppData(user, closed.uid);
+ const inner = acl.fsDescriptor(`${root}/inner`);
+ const home = `/${user.user.username}`;
+
+ // The folder by path, its parent, and the whole home.
+ for (const target of [root, `${home}/AppData`, home]) {
+ const reader = await makeApp(user.user.id!);
+ await grantTo(user, reader, `fs:${target}:read`);
+ expect(
+ await acl.check(asApp(user, reader), inner, 'read'),
+ ).toBe(false);
+ }
+
+ // The user and the app that owns the folder keep their access.
+ expect(await acl.check(user, inner, 'write')).toBe(true);
+ expect(
+ await acl.check(asApp(user, closed), inner, 'write'),
+ ).toBe(true);
+ });
+
+ it('opens to an fs grant while that app shares', async () => {
+ const user = await makeUser();
+ const open = await makeApp(user.user.id!);
+ const root = await seedAppData(user, open.uid);
+ const reader = await makeApp(user.user.id!);
+ await grantTo(user, reader, `fs:${root}:read`);
+ expect(
+ await acl.check(
+ asApp(user, reader),
+ acl.fsDescriptor(`${root}/inner`),
+ 'read',
+ ),
+ ).toBe(true);
+ });
+ });
});
it('leaves one mode standing when two writes race on the same node', async () => {
diff --git a/src/backend/services/acl/ACLService.ts b/src/backend/services/acl/ACLService.ts
index 331654ff5..e30de7a8d 100644
--- a/src/backend/services/acl/ACLService.ts
+++ b/src/backend/services/acl/ACLService.ts
@@ -30,6 +30,7 @@ import {
} from '../../core/actor';
import { PermissionUtil } from '../permission/permissionUtil';
import { MANAGE_PERM_PREFIX } from '../permission/consts';
+import { closedAppDataOwners } from '../permission/appDataScopes';
import { HttpError } from '../../core/http/HttpError.js';
import { actorHasSubscription } from '../metering/enforcement';
import { withRedisLock } from '../../util/redisLock.js';
@@ -49,7 +50,11 @@ export interface ResourceDescriptor {
}
export type AclMode =
- 'see' | 'list' | 'read' | 'write' | typeof MANAGE_PERM_PREFIX;
+ | 'see'
+ | 'list'
+ | 'read'
+ | 'write'
+ | typeof MANAGE_PERM_PREFIX;
/** Duck-typed error shape compatible with APIError consumers (fsv2). */
export interface AclError {
@@ -203,6 +208,15 @@ export class ACLService extends PuterService {
return true;
}
}
+ // Closed whatever the app was granted, on the folder or above it.
+ // The stored ancestor paths count too, so a case variant of the
+ // asked path still names the folder.
+ const closed = await closedAppDataOwners(
+ this.stores.app,
+ [resource.path, ...ancestors.map((a) => a.path)],
+ appUid,
+ );
+ if (closed.size > 0) return false;
}
// Public folders: //Public with read-ish mode, owner must have
diff --git a/src/backend/services/auth/AuthService.appDeletion.test.ts b/src/backend/services/auth/AuthService.appDeletion.test.ts
index a763dba0b..d18f0b740 100644
--- a/src/backend/services/auth/AuthService.appDeletion.test.ts
+++ b/src/backend/services/auth/AuthService.appDeletion.test.ts
@@ -18,9 +18,10 @@
*/
/**
- * A hosted origin's app uid is derived from the origin, so once an app is
- * deleted whoever registers the freed subdomain next is handed the same uid.
- * Sessions made for the earlier app must not authenticate as the new one.
+ * A hosted origin's app uid is derived from the origin, so a deleted app's uid
+ * comes back when its owner uses the site again. Sessions made for the earlier
+ * app must not authenticate as the new one. Someone else who registers the
+ * freed subdomain gets a new uid, so users' KV and AppData stay behind.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -168,10 +169,6 @@ describe('an app uid that returns after its app was deleted', () => {
it("stops the deleted app's sessions authenticating as the next app on its origin", async () => {
const devA = env.users.other;
const visitor = env.users.user;
- const devB = await createTestUser(env.server, {
- username: `devb${uuidv4().slice(0, 8)}`,
- password: 'dev-b-password-123',
- });
const sub = `shop-${uuidv4().slice(0, 8)}`;
const origin = `http://${sub}.site.puter.localhost`;
@@ -203,32 +200,32 @@ describe('an app uid that returns after its app was deleted', () => {
// The earlier app's session was made well before the next app exists.
await backdateSession(sessionUid(tokenA), 3600);
- const bCreate = await call(devB.token, 'puter-subdomains', 'create', {
+ // The same developer puts the site back up.
+ const again = await call(devA.token, 'puter-subdomains', 'create', {
object: {
subdomain: sub,
- root_dir: await mkSiteDir(devB.username),
+ root_dir: await mkSiteDir(devA.username),
},
});
- expect(bCreate.status, bCreate.text).toBe(200);
+ expect(again.status, again.text).toBe(200);
- const { token: tokenB, app_uid: uidB } = await signIn(
+ const { token: tokenNext, app_uid: uidNext } = await signIn(
visitor.token,
origin,
);
- // The uid itself is reused; only the old credentials are cut.
- expect(uidB).toBe(uid);
- expect(sessionUid(tokenB)).not.toBe(sessionUid(tokenA));
+ // The uid and its data come back; only the old credentials are cut.
+ expect(uidNext).toBe(uid);
+ expect(sessionUid(tokenNext)).not.toBe(sessionUid(tokenA));
expect(await isRevoked(sessionUid(tokenA))).toBe(true);
expect((await api('/whoami', tokenA)).status).toBe(401);
- expect((await api('/whoami', tokenB)).status).toBe(200);
- const setB = await call(tokenB, 'puter-kvstore', 'set', {
- key: 'card',
- value: 'b-era',
+ expect((await api('/whoami', tokenNext)).status).toBe(200);
+ const kept = await call(tokenNext, 'puter-kvstore', 'get', {
+ key: 'notes',
});
- expect(setB.status, setB.text).toBe(200);
+ expect((kept.json as { result?: unknown }).result).toBe('a-era');
const getA = await call(tokenA, 'puter-kvstore', 'get', {
- key: 'card',
+ key: 'notes',
});
expect(getA.status).toBe(401);
});
@@ -236,10 +233,6 @@ describe('an app uid that returns after its app was deleted', () => {
it('stops an access token the deleted app minted from authenticating as the next app', async () => {
const devA = env.users.other;
const visitor = env.users.user;
- const devB = await createTestUser(env.server, {
- username: `devb${uuidv4().slice(0, 8)}`,
- password: 'dev-b-password-123',
- });
const sub = `shop-${uuidv4().slice(0, 8)}`;
const origin = `http://${sub}.site.puter.localhost`;
@@ -270,15 +263,15 @@ describe('an app uid that returns after its app was deleted', () => {
await backdateSession(sessionUid(appTokenA), 3600);
await backdateSession(sessionUid(scopedA), 3600);
- // The next developer's sign-in brings the uid back.
- const bCreate = await call(devB.token, 'puter-subdomains', 'create', {
+ // The developer's next sign-in on their own site brings the uid back.
+ const again = await call(devA.token, 'puter-subdomains', 'create', {
object: {
subdomain: sub,
- root_dir: await mkSiteDir(devB.username),
+ root_dir: await mkSiteDir(devA.username),
},
});
- expect(bCreate.status, bCreate.text).toBe(200);
- expect((await signIn(devB.token, origin)).app_uid).toBe(uid);
+ expect(again.status, again.text).toBe(200);
+ expect((await signIn(devA.token, origin)).app_uid).toBe(uid);
expect((await authenticate(scopedA)).reauth?.reason).toBe(
'session_revoked',
@@ -371,3 +364,144 @@ describe('an app uid that returns after its app was deleted', () => {
expect(body.auth_id).toBeUndefined();
});
});
+
+describe("a deleted app's subdomain registered by another user", () => {
+ const writeUserFile = async (
+ username: string,
+ path: string,
+ body: string,
+ ) => {
+ const row = await env.server.stores.user.getByUsername(username);
+ const buf = Buffer.from(body);
+ await env.server.services.fs.write(row!.id, {
+ fileMetadata: {
+ path,
+ size: buf.byteLength,
+ contentType: 'text/plain',
+ overwrite: true,
+ createMissingParents: true,
+ },
+ fileContent: buf,
+ } as never);
+ };
+
+ /**
+ * A visitor stores KV and AppData on developer A's site, `release` takes
+ * A's app away, then developer B registers the same subdomain and the
+ * visitor signs in there.
+ */
+ const reclaim = async (
+ release: (ctx: {
+ devA: { id: number; token: string };
+ sub: string;
+ uid: string;
+ }) => Promise,
+ ) => {
+ const visitor = env.users.user;
+ const devA = await createTestUser(env.server, {
+ username: `deva${uuidv4().slice(0, 8)}`,
+ password: 'dev-a-password-123',
+ });
+ const devB = await createTestUser(env.server, {
+ username: `devb${uuidv4().slice(0, 8)}`,
+ password: 'dev-b-password-123',
+ });
+ const sub = `notes-${uuidv4().slice(0, 8)}`;
+ const origin = `http://${sub}.site.puter.localhost`;
+
+ const aCreate = await call(devA.token, 'puter-subdomains', 'create', {
+ object: {
+ subdomain: sub,
+ root_dir: await mkSiteDir(devA.username),
+ },
+ });
+ expect(aCreate.status, aCreate.text).toBe(200);
+
+ const { token: tokenA, app_uid: uid } = await signIn(
+ visitor.token,
+ origin,
+ );
+ const set = await call(tokenA, 'puter-kvstore', 'set', {
+ key: 'diary',
+ value: 'stored on A',
+ });
+ expect(set.status, set.text).toBe(200);
+ const appDataPath = `/${visitor.username}/AppData/${uid}/private.txt`;
+ await writeUserFile(visitor.username, appDataPath, 'appdata from A');
+
+ const devARow = await env.server.stores.user.getByUsername(
+ devA.username,
+ );
+ await release({
+ devA: { id: devARow!.id, token: devA.token },
+ sub,
+ uid,
+ });
+
+ const bCreate = await call(devB.token, 'puter-subdomains', 'create', {
+ object: {
+ subdomain: sub,
+ root_dir: await mkSiteDir(devB.username),
+ },
+ });
+ expect(bCreate.status, bCreate.text).toBe(200);
+
+ const { token: tokenB, app_uid: uidB } = await signIn(
+ visitor.token,
+ origin,
+ );
+ return { uid, uidB, tokenB, appDataPath };
+ };
+
+ const expectDataStaysBehind = async ({
+ uid,
+ uidB,
+ tokenB,
+ appDataPath,
+ }: Awaited>) => {
+ expect(uidB).not.toBe(uid);
+
+ const kv = await call(tokenB, 'puter-kvstore', 'get', { key: 'diary' });
+ expect(kv.status, kv.text).toBe(200);
+ expect((kv.json as { result?: unknown }).result).toBeNull();
+
+ const read = await api(
+ `/read?file=${encodeURIComponent(appDataPath)}`,
+ tokenB,
+ );
+ expect(read.status).not.toBe(200);
+ };
+
+ it('gives the new site its own uid when A deletes the app and the site', async () => {
+ await expectDataStaysBehind(
+ await reclaim(async ({ devA, sub, uid }) => {
+ const delApp = await call(devA.token, 'puter-apps', 'delete', {
+ uid,
+ });
+ expect(delApp.status, delApp.text).toBe(200);
+ const delSite = await call(
+ devA.token,
+ 'puter-subdomains',
+ 'delete',
+ { id: { subdomain: sub } },
+ );
+ expect(delSite.status, delSite.text).toBe(200);
+ }),
+ );
+ });
+
+ it("gives the new site its own uid when A's account is deleted", async () => {
+ await expectDataStaysBehind(
+ await reclaim(async ({ devA }) => {
+ await env.server.services.userAccount.cascadeDelete(devA.id);
+ // MySQL and Postgres drop the user's apps by FK cascade; the
+ // test sqlite schema has no such FK, so apply it here.
+ await env.server.clients.db.write(
+ 'DELETE FROM `apps` WHERE `owner_user_id` = ?',
+ [devA.id],
+ );
+ await env.server.clients.redis.flushall();
+ }),
+ );
+ });
+});
diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts
index b762cfaa9..16484ea0d 100644
--- a/src/backend/services/auth/AuthService.test.ts
+++ b/src/backend/services/auth/AuthService.test.ts
@@ -2129,6 +2129,136 @@ describe('AuthService (integration)', () => {
).toBe(app.uid);
});
});
+
+ describe("when the app's site is registered to another user", () => {
+ it('gives the new site its own uid and restores the app once its owner holds the site again', async () => {
+ const owner = await makeUser();
+ const other = await makeUser();
+ const sub = `freed-${Math.random().toString(36).slice(2, 10)}`;
+ const origin = `https://${sub}.site.puter.localhost`;
+ const ownSite = await server.stores.subdomain.create({
+ userId: owner.id,
+ subdomain: sub,
+ });
+ const app = await server.stores.app.createFromOrigin(
+ await authService.appUidFromOrigin(origin),
+ authService.canonicalizeOrigin(origin),
+ { ownerUserId: owner.id },
+ );
+ // A path misses the canonical lookup, so only the derived
+ // uid ties this origin to the app.
+ await server.stores.app.update(app.id, {
+ index_url: `${origin}/play/`,
+ });
+ expect(await authService.appUidFromOrigin(origin)).toBe(
+ app.uid,
+ );
+
+ await server.stores.subdomain.deleteByUuid(ownSite.uuid);
+ expect(await authService.appUidFromOrigin(origin)).toBe(
+ app.uid,
+ );
+
+ const otherSite = await server.stores.subdomain.create({
+ userId: other.id,
+ subdomain: sub,
+ });
+ const next = await authService.appUidFromOrigin(origin);
+ expect(next).not.toBe(app.uid);
+ expect(
+ (await authService.appUidsFromOrigins([origin])).get(
+ origin,
+ ),
+ ).toBe(next);
+
+ await server.stores.subdomain.deleteByUuid(otherSite.uuid);
+ await server.stores.subdomain.create({
+ userId: owner.id,
+ subdomain: sub,
+ });
+ expect(await authService.appUidFromOrigin(origin)).toBe(
+ app.uid,
+ );
+ expect(
+ (await authService.appUidsFromOrigins([origin])).get(
+ origin,
+ ),
+ ).toBe(app.uid);
+ });
+ });
+
+ describe('after the origin-derived app is deleted', () => {
+ const uidsFor = async (origin: string) => [
+ await authService.appUidFromOrigin(origin),
+ (await authService.appUidsFromOrigins([origin])).get(origin),
+ ];
+
+ const bootstrapAndDelete = async (
+ origin: string,
+ ownerUserId: number | null,
+ ) => {
+ const app = await server.stores.app.createFromOrigin(
+ await authService.appUidFromOrigin(origin),
+ authService.canonicalizeOrigin(origin),
+ { ownerUserId },
+ );
+ await server.stores.app.delete(app.id);
+ return app.uid;
+ };
+
+ it("gives another user's site on the subdomain a new uid and returns the old one to the app's owner", async () => {
+ const owner = await makeUser();
+ const other = await makeUser();
+ const sub = `gone-${Math.random().toString(36).slice(2, 10)}`;
+ const origin = `https://${sub}.site.puter.localhost`;
+ const ownSite = await server.stores.subdomain.create({
+ userId: owner.id,
+ subdomain: sub,
+ });
+ const uid = await bootstrapAndDelete(origin, owner.id);
+ expect(await uidsFor(origin)).toEqual([uid, uid]);
+
+ await server.stores.subdomain.deleteByUuid(ownSite.uuid);
+ const unregistered = await uidsFor(origin);
+ expect(unregistered[0]).not.toBe(uid);
+ expect(unregistered[1]).toBe(unregistered[0]);
+
+ const otherSite = await server.stores.subdomain.create({
+ userId: other.id,
+ subdomain: sub,
+ });
+ const next = await uidsFor(origin);
+ expect(next[0]).not.toBe(uid);
+ expect(next[1]).toBe(next[0]);
+
+ await server.stores.subdomain.deleteByUuid(otherSite.uuid);
+ await server.stores.subdomain.create({
+ userId: owner.id,
+ subdomain: sub,
+ });
+ expect(await uidsFor(origin)).toEqual([uid, uid]);
+ });
+
+ it('does not return an unowned deleted app uid to a hosted site', async () => {
+ const owner = await makeUser();
+ const sub = `gone-${Math.random().toString(36).slice(2, 10)}`;
+ const origin = `https://${sub}.site.puter.localhost`;
+ const uid = await bootstrapAndDelete(origin, null);
+ await server.stores.subdomain.create({
+ userId: owner.id,
+ subdomain: sub,
+ });
+ expect(await authService.appUidFromOrigin(origin)).not.toBe(
+ uid,
+ );
+ });
+
+ it('keeps the uid of an external origin', async () => {
+ const origin = `https://gone-${uuidv4()}.example.com`;
+ const uid = await bootstrapAndDelete(origin, null);
+ expect(await uidsFor(origin)).toEqual([uid, uid]);
+ });
+ });
});
describe('appUidsFromOrigins', () => {
@@ -3471,6 +3601,142 @@ describe('AuthService (integration)', () => {
});
});
+ describe('revoking every session of a user', () => {
+ const revokers: Array<
+ [string, (user: { id: number }) => Promise]
+ > = [
+ [
+ 'revokeAllSessions',
+ (user) =>
+ authService.revokeAllSessions({
+ user,
+ session: { uid: 'unrelated' },
+ } as unknown as Actor),
+ ],
+ [
+ 'revokeInteractiveSessionsForUserId',
+ (user) =>
+ authService.revokeInteractiveSessionsForUserId(user.id),
+ ],
+ [
+ 'revokeAllSessionsForUserId',
+ (user) => authService.revokeAllSessionsForUserId(user.id),
+ ],
+ ];
+
+ it.each(revokers)(
+ '%s revokes a session the replica has not seen yet',
+ async (_name, revoke) => {
+ const user = await makeUser();
+ const web = await authService.createSessionToken(user, {});
+ const webUuid = (web.session as { uuid: string }).uuid;
+
+ const db = server.clients.db;
+ const original = db.read.bind(db);
+ const read = vi
+ .spyOn(db, 'read')
+ .mockImplementation(
+ async (sql: string, params?: unknown[]) =>
+ sql.includes('FROM `sessions`')
+ ? []
+ : original(sql, params),
+ );
+ // sqlite's `pread` goes through `read`; keep it on the primary.
+ const pread = vi
+ .spyOn(db, 'pread')
+ .mockImplementation((sql: string, params?: unknown[]) =>
+ original(sql, params),
+ );
+ try {
+ await revoke(user);
+ } finally {
+ read.mockRestore();
+ pread.mockRestore();
+ }
+
+ expect(
+ await server.stores.session.getByUuid(webUuid),
+ ).toBeNull();
+ },
+ );
+
+ it.each(revokers)(
+ '%s takes the same few primary round trips however many sessions there are',
+ async (name, revoke) => {
+ const user = await makeUser();
+ const sessions = server.stores.session;
+ const uuids: string[] = [];
+ for (let i = 0; i < 15; i++) {
+ const web = await sessions.create(user.id);
+ const asset = await sessions.create(user.id, {
+ kind: 'asset',
+ parent_session_id: web.uuid,
+ });
+ uuids.push(web.uuid, asset.uuid);
+ }
+ const app = await sessions.getOrCreateApp(
+ user.id,
+ `app-${uuidv4()}`,
+ );
+ const appToken = await sessions.create(user.id, {
+ kind: 'access_token',
+ parent_session_id: app.uuid,
+ });
+ const appUuids = [app.uuid, appToken.uuid];
+
+ const db = server.clients.db;
+ const pread = vi.spyOn(db, 'pread');
+ const write = vi.spyOn(db, 'write');
+ let primaryQueries: string[];
+ try {
+ await revoke(user);
+ } finally {
+ primaryQueries = [
+ ...pread.mock.calls,
+ ...write.mock.calls,
+ ].map(([sql]) => String(sql));
+ pread.mockRestore();
+ write.mockRestore();
+ }
+
+ // List, select, update, re-seed.
+ expect(
+ primaryQueries.filter((sql) => sql.includes('`sessions`')),
+ ).toHaveLength(4);
+ for (const uuid of uuids) {
+ expect(await sessions.getByUuid(uuid)).toBeNull();
+ }
+ for (const uuid of appUuids) {
+ const revoked = (await sessions.getByUuid(uuid)) === null;
+ // Only revokeAllSessions keeps app authorizations.
+ expect(revoked).toBe(name !== 'revokeAllSessions');
+ }
+ },
+ );
+
+ it('a password reset leaves workers and standalone access tokens; a forced sign-out takes them', async () => {
+ const user = await makeUser();
+ const sessions = server.stores.session;
+ const web = await sessions.create(user.id);
+ const worker = await sessions.getOrCreateWorker(user.id, {
+ appUid: null,
+ workerName: `wk-${uuidv4()}`,
+ });
+ const token = await sessions.create(user.id, {
+ kind: 'access_token',
+ });
+
+ await authService.revokeInteractiveSessionsForUserId(user.id);
+ expect(await sessions.getByUuid(web.uuid)).toBeNull();
+ expect(await sessions.getByUuid(worker.uuid)).toBeTruthy();
+ expect(await sessions.getByUuid(token.uuid)).toBeTruthy();
+
+ await authService.revokeAllSessionsForUserId(user.id);
+ expect(await sessions.getByUuid(worker.uuid)).toBeNull();
+ expect(await sessions.getByUuid(token.uuid)).toBeNull();
+ });
+ });
+
describe('purgeEndedSessions', () => {
const GATE_KEY = 'sessions:purge:gate';
const DAY = 24 * 60 * 60;
diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts
index 00471dadc..71e713ab5 100644
--- a/src/backend/services/auth/AuthService.ts
+++ b/src/backend/services/auth/AuthService.ts
@@ -73,6 +73,11 @@ const SESSION_PURGE_PAUSE_MS = 50;
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
+const positiveUserId = (value: unknown): number | null => {
+ const id = typeof value === 'string' ? Number(value) : value;
+ return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
+};
+
/**
* Hosted-asset cookie names by kind. `name` is what gets set; `legacyName` is
* still read through the v2 deprecation window.
@@ -892,18 +897,24 @@ export class AuthService extends PuterService {
* user-driven UI flow and exempts workers + standalone access tokens by
* design.
*
- * Iterates each top-level row through `revokeCascade` so derived rows
- * (asset under web, app-issued access tokens under their app session)
- * follow via the parent_session_id link.
+ * Cascades from every row so derived rows (asset under web, app-issued
+ * access tokens under their app session) follow via the parent_session_id
+ * link.
*/
async revokeAllSessionsForUserId(userId: number): Promise {
if (!userId) return;
- const rows = await this.stores.session.getByUserId(userId);
- for (const row of rows) {
- this.#announceRevocation(
- await this.stores.session.revokeCascade(row.uuid as string),
- );
- }
+ const rows = await this.stores.session.getByUserId(userId, {
+ primary: true,
+ });
+ await this.#revokeCascades(rows.map((row) => row.uuid as string));
+ }
+
+ async #revokeCascades(rootUuids: string[]): Promise {
+ if (rootUuids.length === 0) return;
+ const revoked = (await this.stores.session.revokeCascades(
+ rootUuids,
+ )) as { userId: number; uuids: string[] }[];
+ for (const entry of revoked) this.#announceRevocation(entry);
}
/**
@@ -960,14 +971,14 @@ export class AuthService extends PuterService {
*/
async revokeInteractiveSessionsForUserId(userId: number): Promise {
if (!userId) return;
- const rows = await this.stores.session.getByUserId(userId);
- for (const row of rows) {
- if (row.kind === 'web' || row.kind === 'app') {
- this.#announceRevocation(
- await this.stores.session.revokeCascade(row.uuid as string),
- );
- }
- }
+ const rows = await this.stores.session.getByUserId(userId, {
+ primary: true,
+ });
+ await this.#revokeCascades(
+ rows
+ .filter((row) => row.kind === 'web' || row.kind === 'app')
+ .map((row) => row.uuid as string),
+ );
}
async revokeAllSessions(
@@ -982,19 +993,17 @@ export class AuthService extends PuterService {
const currentUuid = actor.session?.uid;
const rows = await this.stores.session.getByUserId(
actor.user.id as number,
+ { primary: true },
+ );
+ await this.#revokeCascades(
+ rows
+ .filter((row) =>
+ row.kind === 'web'
+ ? opts.includeCurrent || row.uuid !== currentUuid
+ : row.kind === 'app' && opts.includeApps,
+ )
+ .map((row) => row.uuid as string),
);
- for (const row of rows) {
- if (row.kind === 'web') {
- if (!opts.includeCurrent && row.uuid === currentUuid) continue;
- this.#announceRevocation(
- await this.stores.session.revokeCascade(row.uuid as string),
- );
- } else if (row.kind === 'app' && opts.includeApps) {
- this.#announceRevocation(
- await this.stores.session.revokeCascade(row.uuid as string),
- );
- }
- }
}
// -- App / origin resolution -------------------------------------
@@ -1115,15 +1124,64 @@ export class AuthService extends PuterService {
const prefetched =
gen0Uids.length > 0
? await this.stores.app.getByUids(gen0Uids)
- : new Map();
+ : new Map<
+ string,
+ { index_url?: unknown; owner_user_id?: unknown }
+ >();
+
+ // Likewise the deleted app's owner for each hosted gen-0 uid with no
+ // row, and the site owner each owner gets compared against.
+ const deletedGen0Uids = missedAppOrigins
+ .filter(
+ (appOrigin) =>
+ !prefetched.has(gen0ByAppOrigin.get(appOrigin)!) &&
+ this.#hostedSubdomainForOrigin(appOrigin) !== null,
+ )
+ .map((appOrigin) => gen0ByAppOrigin.get(appOrigin)!);
+ const deletedOwners =
+ deletedGen0Uids.length > 0
+ ? await this.stores.app.getDeletedOwnersByUids(deletedGen0Uids)
+ : new Map();
+ const subdomainByAppOrigin = new Map();
+ for (const appOrigin of missedAppOrigins) {
+ const gen0Uid = gen0ByAppOrigin.get(appOrigin)!;
+ const row = prefetched.get(gen0Uid);
+ const ownerId = row
+ ? positiveUserId(row.owner_user_id)
+ : (deletedOwners.get(gen0Uid) ?? null);
+ const subdomain = this.#hostedSubdomainForOrigin(appOrigin);
+ if (ownerId !== null && subdomain) {
+ subdomainByAppOrigin.set(appOrigin, subdomain);
+ }
+ }
+ const siteRows =
+ subdomainByAppOrigin.size > 0
+ ? await this.stores.subdomain.getBySubdomains([
+ ...subdomainByAppOrigin.values(),
+ ])
+ : new Map();
+ const getSiteOwnerId = (appOrigin: string) => {
+ const subdomain = subdomainByAppOrigin.get(appOrigin);
+ const row = subdomain ? siteRows.get(subdomain) : undefined;
+ return row
+ ? Promise.resolve(positiveUserId(row.user_id))
+ : this.#siteOwnerIdForOrigin(appOrigin);
+ };
const derivedByAppOrigin = new Map();
for (const appOrigin of missedAppOrigins) {
const gen0Uid = gen0ByAppOrigin.get(appOrigin)!;
- const uid = await this.#derivedAppUidForOrigin(appOrigin, (uid) =>
- uid === gen0Uid
- ? Promise.resolve(prefetched.get(uid) ?? null)
- : this.stores.app.getByUid(uid),
+ const uid = await this.#derivedAppUidForOrigin(
+ appOrigin,
+ (uid) =>
+ uid === gen0Uid
+ ? Promise.resolve(prefetched.get(uid) ?? null)
+ : this.stores.app.getByUid(uid),
+ getSiteOwnerId,
+ (uid) =>
+ uid === gen0Uid
+ ? Promise.resolve(deletedOwners.get(uid))
+ : this.#deletedAppOwnerId(uid),
);
derivedByAppOrigin.set(appOrigin, uid);
}
@@ -1169,26 +1227,51 @@ export class AuthService extends PuterService {
* `app-`, unless that uid's row now lives at another
* origin. A repointed row keeps its uid (and the data and grants keyed to
* it), so the origin it left moves on to a successor uid instead of
- * resolving to someone's app it no longer serves. `getByUid` is injectable
- * so a batch caller can serve prefetched rows.
+ * resolving to someone's app it no longer serves. The same goes for a row
+ * whose hosted site is now registered to another user, and for a deleted
+ * app's uid on a hosted site its owner no longer holds. The lookups are
+ * injectable so a batch caller can serve prefetched rows.
*/
async #derivedAppUidForOrigin(
origin: string,
getByUid: (
uid: string,
- ) => Promise<{ index_url?: unknown } | null | undefined> = (uid) =>
- this.stores.app.getByUid(uid),
+ ) => Promise<
+ { index_url?: unknown; owner_user_id?: unknown } | null | undefined
+ > = (uid) => this.stores.app.getByUid(uid),
+ getSiteOwnerId: (origin: string) => Promise = (o) =>
+ this.#siteOwnerIdForOrigin(o),
+ getDeletedOwnerId: (
+ uid: string,
+ ) => Promise = (uid) =>
+ this.#deletedAppOwnerId(uid),
): Promise {
+ const hosted = this.#hostedSubdomainForOrigin(origin) !== null;
for (let gen = 0; gen <= MAX_ORIGIN_UID_GENERATIONS; gen++) {
const uid = this.#originUid(origin, gen);
const app = await getByUid(uid);
- if (!app) return uid;
- if (
- typeof app.index_url === 'string' &&
- (await this.#appOriginFor(app.index_url)) === origin
- ) {
- return uid;
+ if (!app) {
+ if (!hosted) return uid;
+ const deletedOwnerId = await getDeletedOwnerId(uid);
+ if (deletedOwnerId === undefined) return uid;
+ if (
+ deletedOwnerId !== null &&
+ deletedOwnerId === (await getSiteOwnerId(origin))
+ ) {
+ return uid;
+ }
+ continue;
}
+ if (
+ typeof app.index_url !== 'string' ||
+ (await this.#appOriginFor(app.index_url)) !== origin
+ ) {
+ continue;
+ }
+ const appOwnerId = positiveUserId(app.owner_user_id);
+ if (appOwnerId === null) return uid;
+ const siteOwnerId = await getSiteOwnerId(origin);
+ if (siteOwnerId === null || siteOwnerId === appOwnerId) return uid;
}
// Past the cap the uid stops being derivable, but the row created for
// it records this origin as its index_url, so the canonical lookup
@@ -1196,6 +1279,11 @@ export class AuthService extends PuterService {
return `app-${uuidv4()}`;
}
+ /** Owner of the deleted app that held `uid`; undefined if none did. */
+ async #deletedAppOwnerId(uid: string): Promise {
+ return (await this.stores.app.getDeletedOwnersByUids([uid])).get(uid);
+ }
+
/**
* Resolve the user who owns the hosted subdomain `origin` points at.
* Returns the `subdomains` row's `user_id` when the origin's host sits
@@ -1206,25 +1294,65 @@ export class AuthService extends PuterService {
* rows.
*/
async subdomainOwnerIdFromOrigin(origin: string): Promise {
+ const subdomain = this.#hostedSubdomainForOrigin(origin);
+ if (!subdomain) return null;
+ const row = await this.stores.subdomain.getBySubdomain(subdomain);
+ return positiveUserId(row?.user_id);
+ }
+
+ /**
+ * Whether `indexUrl` is on a hosted site, or on an origin whose app
+ * identity resolves to an app, owned by someone other than `ownerUserId`. A
+ * private app wins its origin's identity, so an app shouldn't become or
+ * stay private where this is true. Index urls below the origin root never
+ * resolve from an origin, so only the site owner counts for them.
+ */
+ async originHeldByOtherUser(
+ indexUrl: string,
+ ownerUserId: number,
+ ): Promise {
+ const origin = await this.#appOriginFor(indexUrl);
+ if (!origin) return false;
+ const siteOwnerId = await this.#siteOwnerIdForOrigin(origin);
+ if (siteOwnerId !== null) return siteOwnerId !== ownerUserId;
+
+ const path = new URL(indexUrl).pathname.toLowerCase();
+ if (path !== '/' && path !== '/index.html') return false;
+ const holderUid = await this.#findCanonicalAppUidForOrigin(origin);
+ const holder = holderUid
+ ? await this.stores.app.getByUid(holderUid)
+ : null;
+ const holderId = positiveUserId(holder?.owner_user_id);
+ return holderId !== null && holderId !== ownerUserId;
+ }
+
+ /**
+ * {@link subdomainOwnerIdFromOrigin}, confirmed against the primary on a
+ * miss so a site registered a moment ago isn't read as unregistered.
+ */
+ async #siteOwnerIdForOrigin(origin: string): Promise {
+ const subdomain = this.#hostedSubdomainForOrigin(origin);
+ if (!subdomain) return null;
+ const row =
+ (await this.stores.subdomain.getBySubdomain(subdomain)) ??
+ (await this.stores.subdomain.getBySubdomain(subdomain, {
+ primary: true,
+ }));
+ return positiveUserId(row?.user_id);
+ }
+
+ #hostedSubdomainForOrigin(origin: string): string | null {
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
return null;
}
- const subdomain = this.#hostedSubdomainForHost(
+ return this.#hostedSubdomainForHost(
parsed.host.toLowerCase(),
parsed.hostname.toLowerCase(),
this.#getHostingDomains(),
);
- if (!subdomain) return null;
- const row = await this.stores.subdomain.getBySubdomain(subdomain);
- const ownerId = row?.user_id;
- return typeof ownerId === 'number' &&
- Number.isInteger(ownerId) &&
- ownerId > 0
- ? ownerId
- : null;
}
/**
diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts
index bc05200cd..c4cf10bed 100644
--- a/src/backend/services/fs/FSService.test.ts
+++ b/src/backend/services/fs/FSService.test.ts
@@ -3659,7 +3659,9 @@ describe('FSService mkdir, touch, rename and shortcuts', () => {
try {
const error = await caught(() => fs.mkdir(user.userId, { path }));
expect(error.statusCode).toBe(409);
- expect(error.message).toContain(path);
+ expect(error.message).toBe(
+ 'An entry named `racedfile` already exists',
+ );
} finally {
await restore();
}
@@ -6286,6 +6288,106 @@ describe('FSService — cross-app AppData access', () => {
await server.stores.fsEntry.getEntryByPath(contactsFile.path),
).toBeFalsy();
});
+
+ // -- Carrying AppData out of AppData ---------------------------------
+
+ describe('the AppData folder or home as a whole', () => {
+ let appData: FSEntry;
+ let desktop: FSEntry;
+ let closedUid: string;
+ let closedFile: FSEntry;
+
+ beforeEach(async () => {
+ const closed = await makeRealApp(owner.userId, {
+ metadata: JSON.stringify({ share_app_data: false }),
+ });
+ closedUid = closed.uid;
+ closedFile = await seedAppData(closed.uid);
+ appData = (await entryAt(owner, '/AppData'))!;
+ desktop = (await entryAt(owner, '/Desktop'))!;
+ });
+
+ it('refuses an app copying, moving or renaming it past a closed app', async () => {
+ const into = { source: appData, destinationParent: desktop };
+ await expect(
+ asCalendar(() =>
+ fs.copy(owner.userId, { ...into, newName: 'grab' }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ await expect(
+ asCalendar(() =>
+ fs.move(owner.userId, { ...into, newName: 'grab' }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ await expect(
+ asCalendar(() => fs.rename(owner.userId, appData, 'grab')),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ expect(await entryAt(owner, '/Desktop/grab')).toBeFalsy();
+ expect(await entryAt(owner, '/grab')).toBeFalsy();
+
+ // The home carries the same folder.
+ const home = (await entryAt(owner, ''))!;
+ const elsewhere = (await entryAt(await makeUser(), '/Desktop'))!;
+ await expect(
+ asCalendar(() =>
+ fs.copy(owner.userId, {
+ source: home,
+ destinationParent: elsewhere,
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ });
+
+ it('lets the user copy it', async () => {
+ await runWithContext({ actor: owner.actor }, () =>
+ fs.copy(owner.userId, {
+ source: appData,
+ destinationParent: desktop,
+ newName: 'grab',
+ }),
+ );
+ expect(
+ await entryAt(owner, `/Desktop/grab/${closedUid}/state.json`),
+ ).toBeTruthy();
+ });
+
+ it('lets an app copy it while every other app shares', async () => {
+ await fs.remove(owner.userId, {
+ entry: (await entryAt(owner, `/AppData/${closedUid}`))!,
+ recursive: true,
+ });
+ await asCalendar(() =>
+ fs.copy(owner.userId, {
+ source: appData,
+ destinationParent: desktop,
+ newName: 'grab',
+ }),
+ );
+ expect(
+ await entryAt(
+ owner,
+ `/Desktop/grab/${contacts.uid}/state.json`,
+ ),
+ ).toBeTruthy();
+ });
+
+ it("leaves a closed app's entries out of an app's recursive listing", async () => {
+ const list = () =>
+ fs.listDirectoryTreePage(owner.userId, owner.home, {
+ maxDepth: 10,
+ limit: 1000,
+ });
+ const paths = (await asCalendar(list)).entries.map((e) => e.path);
+ expect(paths).toContain(contactsFile.path);
+ expect(paths).not.toContain(closedFile.path);
+ expect(paths).not.toContain(`${owner.home}/AppData/${closedUid}`);
+
+ const asUser = await runWithContext({ actor: owner.actor }, list);
+ expect(asUser.entries.map((e) => e.path)).toContain(
+ closedFile.path,
+ );
+ });
+ });
});
describe('FSService AppData root guard', () => {
diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts
index e9c3a7dd5..2c5742eb1 100644
--- a/src/backend/services/fs/FSService.ts
+++ b/src/backend/services/fs/FSService.ts
@@ -73,8 +73,11 @@ import { AclMode } from '../acl/ACLService.js';
import type { puterServices } from '../index.js';
import {
APP_DATA_FS_MODE_CLASSES,
+ appDataOwnerOf,
appDataPermission,
appDataSharingAllowed,
+ carriesClosedAppData,
+ closedAppDataOwners,
} from '../permission/appDataScopes.js';
import { MANAGE_PERM_PREFIX } from '../permission/consts.js';
import { PermissionUtil } from '../permission/permissionUtil.js';
@@ -142,11 +145,8 @@ const foreignAppDataOwner = (
username: string,
ownAppUid: string,
): string | null => {
- const segments = path.split('/');
- if (segments[1] !== username || segments[2]?.toLowerCase() !== 'appdata') {
- return null;
- }
- const appUid = segments[3];
+ if (path.split('/')[1] !== username) return null;
+ const appUid = appDataOwnerOf(path);
if (!appUid || appUid === ownAppUid) return null;
return appUid;
};
@@ -829,7 +829,7 @@ export class FSService extends PuterService {
if (pathReservedInBatch) {
throw new HttpError(
409,
- `Batch contains duplicate target path: ${normalizedInput.path}`,
+ `Batch contains duplicate target path: ${pathPosix.basename(normalizedInput.path)}`,
{ legacyCode: 'conflict' },
);
}
@@ -850,7 +850,7 @@ export class FSService extends PuterService {
} else if (pathReservedInBatch) {
throw new HttpError(
409,
- `Batch contains duplicate target path: ${normalizedInput.path}`,
+ `Batch contains duplicate target path: ${pathPosix.basename(normalizedInput.path)}`,
{ legacyCode: 'conflict' },
);
}
@@ -2322,7 +2322,7 @@ export class FSService extends PuterService {
if (directoryPathSet.has(targetPath)) {
throw new HttpError(
409,
- `Batch contains duplicate target path: ${targetPath}`,
+ `Batch contains duplicate target path: ${pathPosix.basename(targetPath)}`,
{ legacyCode: 'conflict' },
);
}
@@ -3724,7 +3724,29 @@ export class FSService extends PuterService {
sortOrder?: 'asc' | 'desc' | null;
},
): Promise<{ entries: FSEntry[]; cursor?: string }> {
- return this.stores.fsEntry.listDescendantsPage(userId, path, options);
+ const page = await this.stores.fsEntry.listDescendantsPage(
+ userId,
+ path,
+ options,
+ );
+ // Listing a home or AppData reaches into every app's AppData.
+ const app = (Context.get('actor') as Actor | undefined)?.effectiveApp;
+ if (!app) return page;
+ const closed = await closedAppDataOwners(
+ this.stores.app,
+ page.entries.map((entry) => entry.path),
+ app.uid,
+ );
+ if (closed.size === 0) return page;
+ return {
+ ...page,
+ entries: page.entries.filter(
+ (entry) =>
+ !closed.has(
+ appDataOwnerOf(entry.path)?.toLowerCase() ?? '',
+ ),
+ ),
+ };
}
async countDirectoryTree(
@@ -4001,7 +4023,7 @@ export class FSService extends PuterService {
} else {
throw new HttpError(
409,
- `An entry already exists at ${targetPath}`,
+ `An entry named \`${name}\` already exists`,
{ legacyCode: 'conflict' },
);
}
@@ -4035,7 +4057,7 @@ export class FSService extends PuterService {
if (raced) {
throw new HttpError(
409,
- `An entry already exists at ${insertedPath}`,
+ `An entry named \`${name}\` already exists`,
{ legacyCode: 'conflict' },
);
}
@@ -4098,6 +4120,7 @@ export class FSService extends PuterService {
if (entry.name === newName) return entry;
this.#assertAppDataRootsStay(entry.path);
await this.#assertCrossAppDeleteAllowed(entry.path);
+ await this.#assertCarriesNoClosedAppData(entry, 'rename');
const parentPath = pathPosix.dirname(entry.path);
const newPath =
@@ -4106,9 +4129,11 @@ export class FSService extends PuterService {
// Reject if another entry already owns the target path.
const collision = await this.stores.fsEntry.getEntryByPath(newPath);
if (collision && collision.uuid !== entry.uuid) {
- throw new HttpError(409, `An entry already exists at ${newPath}`, {
- legacyCode: 'conflict',
- });
+ throw new HttpError(
+ 409,
+ `An entry named \`${newName}\` already exists`,
+ { legacyCode: 'conflict' },
+ );
}
const updated = await this.stores.fsEntry.updateEntry(entry.uuid, {
@@ -4163,7 +4188,7 @@ export class FSService extends PuterService {
} else {
throw new HttpError(
409,
- `An entry already exists at ${childPath}`,
+ `An entry named \`${name}\` already exists`,
{ legacyCode: 'conflict' },
);
}
@@ -4270,6 +4295,22 @@ export class FSService extends PuterService {
);
}
+ /** An app may not carry another app's closed AppData out of AppData. */
+ async #assertCarriesNoClosedAppData(
+ source: FSEntry,
+ verb: string,
+ ): Promise {
+ const app = (Context.get('actor') as Actor | undefined)?.effectiveApp;
+ if (!app) return;
+ if (await carriesClosedAppData(this.stores, source, app.uid)) {
+ throw new HttpError(
+ 403,
+ `Cannot ${verb} a folder holding another app's private data`,
+ { legacyCode: 'forbidden' },
+ );
+ }
+ }
+
/**
* Only the account moving within its own tree (source _and_ destination
* both its own) may set these freely; anyone else gets them stripped, or
@@ -4840,6 +4881,7 @@ export class FSService extends PuterService {
// The source only: moving *into* another app's AppData is a write, and
// ACL plus the fs:write class already cover that.
await this.#assertCrossAppDeleteAllowed(source.path);
+ await this.#assertCarriesNoClosedAppData(source, 'move');
await this.#assertCanRestructure(source, userId);
// Write inside a shared folder reorganizes it, it does not empty it.
if (
@@ -5031,14 +5073,10 @@ export class FSService extends PuterService {
// v1 wire contract: clients (the GUI's move/paste flows among them)
// key on `item_with_same_name_exists` + `entry_name` to offer a
// replace/skip prompt.
- throw new HttpError(
- 409,
- `An entry already exists at ${occupant.path}`,
- {
- legacyCode: 'item_with_same_name_exists',
- fields: { entry_name: name },
- },
- );
+ throw new HttpError(409, `An entry named \`${name}\` already exists`, {
+ legacyCode: 'item_with_same_name_exists',
+ fields: { entry_name: name },
+ });
}
/**
@@ -5077,6 +5115,7 @@ export class FSService extends PuterService {
{ legacyCode: 'cannot_copy_directory_into_itself' },
);
}
+ await this.#assertCarriesNoClosedAppData(source, 'copy');
let name = input.newName ?? source.name;
this.#assertUsableName(name);
@@ -5119,7 +5158,7 @@ export class FSService extends PuterService {
// v1 wire contract, as in move() above.
throw new HttpError(
409,
- `An entry already exists at ${targetPath}`,
+ `An entry named \`${name}\` already exists`,
{
legacyCode: 'item_with_same_name_exists',
fields: { entry_name: name },
diff --git a/src/backend/services/localworker/LocalWorkerService.test.ts b/src/backend/services/localworker/LocalWorkerService.test.ts
index c1d484c28..4610eefbc 100644
--- a/src/backend/services/localworker/LocalWorkerService.test.ts
+++ b/src/backend/services/localworker/LocalWorkerService.test.ts
@@ -192,6 +192,36 @@ describe('LocalWorkerService.reconstructDeployArgs', () => {
),
).rejects.toThrow(/source file not found/);
});
+
+ it('refuses a worker whose source file belongs to another user', async () => {
+ const other = await createTestUser(server, {
+ username: 'lws-other',
+ password: 'local-worker-password',
+ });
+ const otherRow = (await server.stores.user.getByUsername(
+ other.username,
+ ))!;
+ const path = `/${other.username}/worker-f.js`;
+ await server.services.fs.write(otherRow.id, {
+ fileMetadata: {
+ path,
+ size: 2,
+ contentType: 'text/javascript',
+ overwrite: true,
+ },
+ fileContent: Readable.from(Buffer.from('//')),
+ });
+ const foreign = await server.stores.fsEntry.getEntryByPath(path);
+
+ await expect(
+ localWorkers.reconstructDeployArgs(
+ 'worker-f',
+ await seedWorkerRow('worker-f', {
+ root_dir_id: foreign!.id as number,
+ }),
+ ),
+ ).rejects.toThrow(/not owned by the worker's owner/);
+ });
});
describe('LocalWorkerService.cfCallLocal', () => {
diff --git a/src/backend/services/localworker/LocalWorkerService.ts b/src/backend/services/localworker/LocalWorkerService.ts
index 2dc475c39..0718d02e5 100644
--- a/src/backend/services/localworker/LocalWorkerService.ts
+++ b/src/backend/services/localworker/LocalWorkerService.ts
@@ -295,6 +295,11 @@ export class LocalWorkerService extends PuterService {
`Local: worker ${workerName} source file not found (id=${row.root_dir_id})`,
);
}
+ if (sourceEntry.userId !== ownerUser.id) {
+ throw new Error(
+ `Local: worker ${workerName} source file is not owned by the worker's owner`,
+ );
+ }
const loaded = await loadFileInput(
{
diff --git a/src/backend/services/permission/appDataScopes.ts b/src/backend/services/permission/appDataScopes.ts
index a54da65d4..4a2690a80 100644
--- a/src/backend/services/permission/appDataScopes.ts
+++ b/src/backend/services/permission/appDataScopes.ts
@@ -17,6 +17,9 @@
* along with this program. If not, see .
*/
+import { validate as validateUuid } from 'uuid';
+import type { FSEntry } from '../../stores/fs/FSEntry';
+import type { FSEntryStore } from '../../stores/fs/FSEntryStore';
import { PermissionUtil } from './permissionUtil';
/** Root of the cross-app application-data permission namespace. */
@@ -145,6 +148,21 @@ export const parseAppDataPermission = (
return { targetAppUid, store, op };
};
+/**
+ * The app whose `//AppData/` subtree `path` sits in, under
+ * any user, or null. `AppData` matches case-insensitively, like the column.
+ * Only an `app-` name counts: any other folder there belongs to no app,
+ * and looking it up would only miss (or, outside latin1, fail the query).
+ */
+export const appDataOwnerOf = (path: string): string | null => {
+ const segments = path.split('/');
+ if (segments[2]?.toLowerCase() !== 'appdata') return null;
+ const appUid = segments[3] ?? '';
+ return /^app-/i.test(appUid) && validateUuid(appUid.slice(4))
+ ? appUid
+ : null;
+};
+
/**
* Whether `app` lets other apps reach its per-user data.
*
@@ -164,3 +182,78 @@ export const appDataSharingAllowed = (app: { metadata?: unknown }): boolean => {
if (!metadata || typeof metadata !== 'object') return true;
return (metadata as { share_app_data?: unknown }).share_app_data !== false;
};
+
+type AppLookup = {
+ getByUids(
+ uids: string[],
+ ): Promise