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({