fix(fs): hide the issuer's home from scoped-token root listings (PUT-2053) (#4047)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-04 00:22:43 -07:00
1 parent 3defae3cfc
commit 26abd4e4a5
10 files changed
+410 -7

No files matched your search

@@ -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', () => {
@@ -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(
@@ -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!;
@@ -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(
@@ -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(
`<D:href>/${username}/</D:href>`,
);
}
});
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('<D:href>/</D:href>');
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(
`<D:href>/${username}/</D:href>`,
);
});
});
it('describes a single file when asked for one', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/single.md`;
@@ -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 `/<owner>/<uuid>/<name>`, where the uuid stands in
* for the folder the owner keeps it in. Only the `<owner>` 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),
);
+92 -2
View File
@@ -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(
+46 -3
View File
@@ -17,9 +17,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<FSEntry[]> {
const entries: FSEntry[] = [];
const seenPaths = new Set<string>();
@@ -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<FSEntry, 'uuid'>,
mode: AclMode,
aclService: ACLService,
permissionStore: PermissionStore,
): Promise<boolean> {
if (!isAccessTokenActor(actor) || isAccountContext(actor)) return true;
return permissionStore.hasAnyAccessTokenPerm(
actor.accessToken!.uid,
aclService.permissionsFor(home.uuid, mode),
);
}
@@ -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 ----------------------------
@@ -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<boolean> {
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<void> {
await this.publishCacheKeys({