diff --git a/src/backend/controllers/fs/FSController.http.test.ts b/src/backend/controllers/fs/FSController.http.test.ts
index 8b6947442..cec71cfad 100644
--- a/src/backend/controllers/fs/FSController.http.test.ts
+++ b/src/backend/controllers/fs/FSController.http.test.ts
@@ -353,6 +353,70 @@ describe('GET /fs/readdir over HTTP', () => {
expect(stat.shares).toEqual([]);
});
});
+
+ describe('the root listing', () => {
+ const mint = async (permissions: string[]) => {
+ const response = await fetch(
+ new URL('/auth/create-access-token', env.apiOrigin),
+ {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${env.users.user.token}`,
+ },
+ body: JSON.stringify({ permissions }),
+ },
+ );
+ expect(response.status).toBe(200);
+ return ((await response.json()) as { token: string }).token;
+ };
+
+ const listRoot = async (token: string) => {
+ const response = await fetch(
+ readdirUrl({ path: '/', auth_token: token }),
+ );
+ expect(response.status).toBe(200);
+ return response.text();
+ };
+
+ const rootPaths = async (token: string) => {
+ const entries = JSON.parse(await listRoot(token)) as Array<{
+ path: string;
+ }>;
+ return entries.map((entry) => entry.path);
+ };
+
+ const entryAt = async (path: string) =>
+ (await env.server.stores.fsEntry.getEntryByPath(path))!;
+
+ it('lists the home for a session and a full-access token', async () => {
+ const { username, token, apiToken } = env.users.user;
+ for (const credential of [token, apiToken]) {
+ expect(await rootPaths(credential)).toEqual([`/${username}`]);
+ }
+ });
+
+ it('leaves the home out for a token scoped below it', async () => {
+ const { username } = env.users.user;
+ const home = await entryAt(`/${username}`);
+ const documents = await entryAt(`/${username}/Documents`);
+ const scoped = await mint([`fs:${documents.uuid}:read`]);
+
+ const raw = await listRoot(scoped);
+
+ expect(JSON.parse(raw)).toEqual([]);
+ expect(raw).not.toContain(home.uuid);
+ expect(raw).not.toContain(username);
+ });
+
+ it('lists the home for a token that can list it', async () => {
+ const { username } = env.users.user;
+ const home = await entryAt(`/${username}`);
+ const scoped = await mint([`fs:${home.uuid}:list`]);
+
+ expect(await rootPaths(scoped)).toEqual([`/${username}`]);
+ });
+ });
});
describe('POST /fs/completeWrite over HTTP', () => {
diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts
index c20e31a18..56c12e804 100644
--- a/src/backend/controllers/fs/FSController.ts
+++ b/src/backend/controllers/fs/FSController.ts
@@ -1197,6 +1197,8 @@ export class FSController extends PuterController {
const rootChildren = await listRootEntries(
actor,
this.stores.fsEntry,
+ this.services.acl,
+ this.stores.permission,
);
const rootSuggestions =
await this.services.suggestedApps.getSuggestedAppsForEntries(
diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts
index 523d10e43..00a77afad 100644
--- a/src/backend/controllers/fs/LegacyFSController.test.ts
+++ b/src/backend/controllers/fs/LegacyFSController.test.ts
@@ -28,6 +28,7 @@ import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { DEFAULT_FREE_SUBSCRIPTION } from '../../services/metering/consts.js';
+import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { setupTestServer } from '../../testUtil.js';
import { signFile } from '../../util/fileSigning.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
@@ -1138,6 +1139,71 @@ describe('LegacyFSController.readdir', () => {
expect(Array.isArray(captured.body)).toBe(true);
});
+ describe('at the root, per credential', () => {
+ const listRoot = async (actor: Actor) => {
+ const { res, captured } = makeRes();
+ await withActor(actor, () =>
+ controller.readdir(
+ makeReq({ body: { path: '/' }, actor }),
+ res,
+ ),
+ );
+ return captured.body as Array<{ path: string }>;
+ };
+
+ const tokenActorFor = async (userId: number, permission: string) => {
+ const user = (await server.stores.user.getById(userId))!;
+ const token = await server.services.auth.createAccessToken(
+ makeActor({ user }),
+ [[permission]],
+ );
+ return (await server.services.auth.authenticateFromToken(token))!;
+ };
+
+ it('lists the home for a session and a full-access token', async () => {
+ const { actor, userId } = await makeUser();
+ const username = actor.user!.username!;
+ const user = (await server.stores.user.getById(userId))!;
+ const fullAccess = await tokenActorFor(userId, FULL_API_ACCESS);
+
+ for (const credential of [makeActor({ user }), fullAccess]) {
+ const entries = await listRoot(credential);
+ expect(entries.map((entry) => entry.path)).toEqual([
+ `/${username}`,
+ ]);
+ }
+ });
+
+ it('leaves the home out for a token scoped below it', async () => {
+ const { actor, userId } = await makeUser();
+ const username = actor.user!.username!;
+ const documents = (await server.stores.fsEntry.getEntryByPath(
+ `/${username}/Documents`,
+ ))!;
+ const scoped = await tokenActorFor(
+ userId,
+ `fs:${documents.uuid}:read`,
+ );
+
+ expect(await listRoot(scoped)).toEqual([]);
+ });
+
+ it('lists the home for a token that can list it', async () => {
+ const { actor, userId } = await makeUser();
+ const username = actor.user!.username!;
+ const home = (await server.stores.fsEntry.getEntryByPath(
+ `/${username}`,
+ ))!;
+ const scoped = await tokenActorFor(userId, `fs:${home.uuid}:list`);
+
+ const entries = await listRoot(scoped);
+
+ expect(entries.map((entry) => entry.path)).toEqual([
+ `/${username}`,
+ ]);
+ });
+ });
+
it('rejects readdir on a non-directory with 400', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts
index fc36ab3ba..97bcc8c36 100644
--- a/src/backend/controllers/fs/LegacyFSController.ts
+++ b/src/backend/controllers/fs/LegacyFSController.ts
@@ -567,6 +567,8 @@ export class LegacyFSController extends PuterController {
const rootChildren = await listRootEntries(
actor,
this.stores.fsEntry,
+ this.services.acl,
+ this.stores.permission,
);
const rootSuggestions =
await this.services.suggestedApps.getSuggestedAppsForEntries(
diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts
index 7e9d59d47..3ea851ba4 100644
--- a/src/backend/controllers/webdav/WebDAVController.test.ts
+++ b/src/backend/controllers/webdav/WebDAVController.test.ts
@@ -10,7 +10,9 @@ import { PuterRouter } from '../../core/http/PuterRouter.js';
import type { RouteDescriptor } from '../../core/http/types.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
+import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
+import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { WebDAVController } from './WebDAVController.js';
@@ -1546,6 +1548,98 @@ describe('WebDAVController verbs', () => {
);
});
+ describe('root collection per credential', () => {
+ // Own address, so the per-IP auth failure bucket the rest of the
+ // file draws on can't refuse these.
+ const propfindRoot = (authorization: string) =>
+ dispatch({
+ method: 'PROPFIND',
+ path: '/',
+ headers: { authorization },
+ ip: '203.0.113.53',
+ });
+
+ const mintToken = async (userId: number, permission: string) => {
+ const user = (await server.stores.user.getById(userId))!;
+ return server.services.auth.createAccessToken(
+ makeActor({ user }),
+ [[permission]],
+ );
+ };
+
+ it('lists the home for a password login and a full-access token', async () => {
+ const username = `webdav-root-${Math.random()
+ .toString(36)
+ .slice(2, 10)}`;
+ const created = await server.stores.user.create({
+ username,
+ uuid: uuidv4(),
+ password: await bcryptHash('correct-horse', 4),
+ email: `${username}@test.local`,
+ free_storage: 100 * 1024 * 1024,
+ requires_email_confirmation: false,
+ });
+ await generateDefaultFsentries(
+ server.clients.db,
+ server.stores.user,
+ created,
+ );
+ const fullAccess = await mintToken(created.id, FULL_API_ACCESS);
+
+ for (const authorization of [
+ basicAuth(username, 'correct-horse'),
+ basicAuth('-token', fullAccess),
+ ]) {
+ const captured = await propfindRoot(authorization);
+ expect(captured.statusCode).toBe(207);
+ expect(captured.body as string).toContain(
+ `/${username}/`,
+ );
+ }
+ });
+
+ it('leaves the home out for a token scoped below it', async () => {
+ const { userId, username } = await makeUser();
+ const home = (await server.stores.fsEntry.getEntryByPath(
+ `/${username}`,
+ ))!;
+ const documents = (await server.stores.fsEntry.getEntryByPath(
+ `/${username}/Documents`,
+ ))!;
+ const scoped = await mintToken(
+ userId,
+ `fs:${documents.uuid}:read`,
+ );
+
+ const captured = await propfindRoot(
+ basicAuth('-token', scoped),
+ );
+
+ expect(captured.statusCode).toBe(207);
+ const xml = captured.body as string;
+ expect(xml).toContain('/');
+ expect(xml).not.toContain(`/${username}/`);
+ expect(xml).not.toContain(home.uuid);
+ });
+
+ it('lists the home for a token that can read it', async () => {
+ const { userId, username } = await makeUser();
+ const home = (await server.stores.fsEntry.getEntryByPath(
+ `/${username}`,
+ ))!;
+ const scoped = await mintToken(userId, `fs:${home.uuid}:read`);
+
+ const captured = await propfindRoot(
+ basicAuth('-token', scoped),
+ );
+
+ expect(captured.statusCode).toBe(207);
+ expect(captured.body as string).toContain(
+ `/${username}/`,
+ );
+ });
+ });
+
it('describes a single file when asked for one', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/single.md`;
diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts
index bace61112..e39a30c4d 100644
--- a/src/backend/controllers/webdav/WebDAVController.ts
+++ b/src/backend/controllers/webdav/WebDAVController.ts
@@ -48,6 +48,7 @@ import {
import type { RouteOptions } from '../../core/http/types.js';
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
+import { rootShowsHome } from '../../services/fs/rootListing.js';
import {
maskEntryPath,
parseMaskedSharePath,
@@ -624,7 +625,8 @@ export class WebDAVController extends PuterController {
/**
* The root collection: the caller's own home directory, plus one collection
- * per person who has shared something with them.
+ * per person who has shared something with them. A scoped access token sees
+ * the home only when it can read it.
*
* A share is addressed `///`, where the uuid stands in
* for the folder the owner keeps it in. Only the `` segment is a
@@ -639,7 +641,16 @@ export class WebDAVController extends PuterController {
const home = await this.stores.fsEntry.getEntryByPath(
`/${actor.user!.username}`,
);
- if (home) {
+ if (
+ home &&
+ (await rootShowsHome(
+ actor,
+ home,
+ 'read',
+ this.services.acl,
+ this.stores.permission,
+ ))
+ ) {
responses.push(
propfindEntry(maskEntryPath(home), home, home.isDir),
);
diff --git a/src/backend/services/fs/rootListing.test.ts b/src/backend/services/fs/rootListing.test.ts
index 1f4ae570f..276660728 100644
--- a/src/backend/services/fs/rootListing.test.ts
+++ b/src/backend/services/fs/rootListing.test.ts
@@ -19,11 +19,12 @@
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
-import type { Actor } from '../../core/actor.js';
+import { makeActor, type Actor } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
+import { FULL_API_ACCESS } from '../permission/consts.js';
import type { PermissionService } from '../permission/PermissionService.js';
import { listRootEntries } from './rootListing.js';
@@ -66,7 +67,23 @@ const makeUser = async () => {
return { userId: created.id, username, actor };
};
-const listFor = (actor: Actor) => listRootEntries(actor, fsEntryStore);
+const listFor = (actor: Actor) =>
+ listRootEntries(
+ actor,
+ fsEntryStore,
+ server.services.acl,
+ server.stores.permission,
+ );
+
+// The actor a request bearing a freshly minted access token resolves to.
+const tokenActorFor = async (userId: number, permissions: string[]) => {
+ const user = (await server.stores.user.getById(userId))!;
+ const token = await server.services.auth.createAccessToken(
+ makeActor({ user }),
+ permissions.map((permission) => [permission]),
+ );
+ return (await server.services.auth.authenticateFromToken(token))!;
+};
describe('listRootEntries', () => {
it('shows the actor their own home directory, exactly once', async () => {
@@ -170,6 +187,79 @@ describe('listRootEntries', () => {
expect(entries).toEqual([]);
});
+ it('leaves the home out for a token scoped below it, in one grant read', async () => {
+ const user = await makeUser();
+ const documents = (await fsEntryStore.getEntryByPath(
+ `/${user.username}/Documents`,
+ ))!;
+ const actor = await tokenActorFor(user.userId, [
+ `fs:${documents.uuid}:read`,
+ ]);
+ const hasAny = vi.spyOn(
+ server.stores.permission,
+ 'hasAnyAccessTokenPerm',
+ );
+ const scan = vi.spyOn(permissionService, 'scan');
+
+ try {
+ await expect(listFor(actor)).resolves.toEqual([]);
+ expect(hasAny).toHaveBeenCalledTimes(1);
+ expect(scan).not.toHaveBeenCalled();
+ } finally {
+ hasAny.mockRestore();
+ scan.mockRestore();
+ }
+ });
+
+ it('shows the home to a token that can list it', async () => {
+ const user = await makeUser();
+ const home = (await fsEntryStore.getEntryByPath(`/${user.username}`))!;
+ const actor = await tokenActorFor(user.userId, [
+ `fs:${home.uuid}:list`,
+ ]);
+
+ const entries = await listFor(actor);
+
+ expect(entries.map((entry) => entry.path)).toEqual([
+ `/${user.username}`,
+ ]);
+ });
+
+ it('reads no token grants for a session or a full-access token', async () => {
+ const user = await makeUser();
+ const row = (await server.stores.user.getById(user.userId))!;
+ const session = makeActor({ user: row });
+ const fullAccess = await tokenActorFor(user.userId, [FULL_API_ACCESS]);
+ const hasAny = vi.spyOn(
+ server.stores.permission,
+ 'hasAnyAccessTokenPerm',
+ );
+
+ try {
+ for (const actor of [session, fullAccess]) {
+ const entries = await listFor(actor);
+ expect(entries.map((entry) => entry.path)).toEqual([
+ `/${user.username}`,
+ ]);
+ }
+ expect(hasAny).not.toHaveBeenCalled();
+ } finally {
+ hasAny.mockRestore();
+ }
+ });
+
+ it('shows the home to an app session', async () => {
+ const user = await makeUser();
+ const row = (await server.stores.user.getById(user.userId))!;
+ const actor = makeActor({ user: row, app: { uid: `app-${uuidv4()}` } });
+
+ const entries = await listFor(actor);
+
+ expect(entries.map((entry) => entry.path)).toEqual([
+ `/${user.username}`,
+ ]);
+ });
+
it('returns nothing for an actor with no user id or username', async () => {
await expect(listFor({ user: {} })).resolves.toEqual([]);
await expect(
diff --git a/src/backend/services/fs/rootListing.ts b/src/backend/services/fs/rootListing.ts
index 420562ba1..88527ec46 100644
--- a/src/backend/services/fs/rootListing.ts
+++ b/src/backend/services/fs/rootListing.ts
@@ -17,9 +17,15 @@
* along with this program. If not, see .
*/
-import type { Actor } from '../../core/actor.js';
+import {
+ isAccessTokenActor,
+ isAccountContext,
+ type Actor,
+} from '../../core/actor.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
+import type { PermissionStore } from '../../stores/permission/PermissionStore.js';
+import type { AclMode, ACLService } from '../acl/ACLService.js';
/**
* Synthesize the listing for the virtual root `/`. There is no fsentry row at
@@ -27,11 +33,14 @@ import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
*
* Issuer homes are deliberately absent: a grant on a file says nothing about
* its ancestors, so listing them advertised folders `readdir` then refused to
- * open. Shares are reached through the sharing API instead.
+ * open. Shares are reached through the sharing API instead. For the same reason
+ * a scoped access token sees its own home only when it can list it.
*/
export async function listRootEntries(
actor: Actor,
fsEntryStore: FSEntryStore,
+ aclService: ACLService,
+ permissionStore: PermissionStore,
): Promise {
const entries: FSEntry[] = [];
const seenPaths = new Set();
@@ -70,5 +79,39 @@ export async function listRootEntries(
await pushByUsername(actor.user.username);
- return entries;
+ const shown: FSEntry[] = [];
+ for (const entry of entries) {
+ if (
+ await rootShowsHome(
+ actor,
+ entry,
+ 'list',
+ aclService,
+ permissionStore,
+ )
+ ) {
+ shown.push(entry);
+ }
+ }
+ return shown;
+}
+
+/**
+ * Whether a root listing may show the actor's home. Only a scoped access token
+ * is checked, against its own grants on the home in one read. The issuer needs
+ * no check: it is the owner or an app acting for them, and root shows the home
+ * to both.
+ */
+export async function rootShowsHome(
+ actor: Actor,
+ home: Pick,
+ mode: AclMode,
+ aclService: ACLService,
+ permissionStore: PermissionStore,
+): Promise {
+ if (!isAccessTokenActor(actor) || isAccountContext(actor)) return true;
+ return permissionStore.hasAnyAccessTokenPerm(
+ actor.accessToken!.uid,
+ aclService.permissionsFor(home.uuid, mode),
+ );
}
diff --git a/src/backend/stores/permission/PermissionStore.test.ts b/src/backend/stores/permission/PermissionStore.test.ts
index a718ec766..c60da7be7 100644
--- a/src/backend/stores/permission/PermissionStore.test.ts
+++ b/src/backend/stores/permission/PermissionStore.test.ts
@@ -668,6 +668,28 @@ describe('PermissionStore', () => {
await store.hasAccessTokenPerm(`tok-${uuidv4()}`, 'driver:kv'),
).toBe(false);
});
+
+ it('answers whether the token holds any of several grants', async () => {
+ const tokenUid = `tok-${uuidv4()}`;
+ await server.clients.db.write(
+ 'INSERT INTO `access_token_permissions` (`token_uid`, `permission`) VALUES (?, ?)',
+ [tokenUid, 'fs:abc:read'],
+ );
+
+ expect(
+ await store.hasAnyAccessTokenPerm(tokenUid, [
+ 'fs:abc:list',
+ 'fs:abc:read',
+ ]),
+ ).toBe(true);
+ expect(
+ await store.hasAnyAccessTokenPerm(tokenUid, [
+ 'fs:abc:write',
+ 'fs:other:read',
+ ]),
+ ).toBe(false);
+ expect(await store.hasAnyAccessTokenPerm(tokenUid, [])).toBe(false);
+ });
});
// -- cache generation + scan/check caches ----------------------------
diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts
index 4bec11e25..747a5d7fc 100644
--- a/src/backend/stores/permission/PermissionStore.ts
+++ b/src/backend/stores/permission/PermissionStore.ts
@@ -1171,6 +1171,15 @@ export class PermissionStore extends PuterStore {
return all.includes(permission);
}
+ /** Whether the token holds any of `permissions`, from one read. */
+ async hasAnyAccessTokenPerm(
+ tokenUid: string,
+ permissions: readonly string[],
+ ): Promise {
+ const all = await this.#readAccessTokenPerms(tokenUid);
+ return permissions.some((permission) => all.includes(permission));
+ }
+
/** Call from AuthService after it mutates `access_token_permissions`. */
async invalidateAccessTokenPerms(tokenUid: string): Promise {
await this.publishCacheKeys({