mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-12 14:50:39 +00:00
fix(fs): hide the issuer's home from scoped-token root listings (PUT-2053) (#4047)
This commit is contained in:
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),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user