From 32d4fca0a384d9535bb43992c0e2d9ec5d7bd305 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 4 Oct 2026 04:40:41 -0700 Subject: [PATCH] fix(fs): hide the issuer's home uid from a scoped token's entries A token scoped below the home could still learn the home's uid as the parent_uid of a direct child (e.g. stat on ~/Documents). For access-token actors that can't list the home, a direct child's parent uid is now null in the v2 and legacy entry shapes, using the same rule as the root listing. --- .../controllers/fs/FSController.test.ts | 84 +++++++++++++++++++ src/backend/controllers/fs/FSController.ts | 36 +++++++- .../controllers/fs/LegacyFSController.test.ts | 82 ++++++++++++++++++ .../controllers/fs/LegacyFSController.ts | 19 +++-- .../controllers/fs/legacyFsHelpers.test.ts | 17 ++++ src/backend/controllers/fs/legacyFsHelpers.ts | 14 +++- src/backend/services/fs/rootListing.test.ts | 81 +++++++++++++++++- src/backend/services/fs/rootListing.ts | 34 ++++++++ 8 files changed, 354 insertions(+), 13 deletions(-) diff --git a/src/backend/controllers/fs/FSController.test.ts b/src/backend/controllers/fs/FSController.test.ts index 5bfa626c1..4c27bde43 100644 --- a/src/backend/controllers/fs/FSController.test.ts +++ b/src/backend/controllers/fs/FSController.test.ts @@ -843,6 +843,90 @@ describe('FSController.statEntry', () => { ); expect((captured.body as { name: string }).name).toBe('share-budget'); }); + + describe('parentUid of a home child', () => { + 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('nulls it for a token scoped below the home', async () => { + const { userId } = await makeUser(); + const user = (await server.stores.user.getById(userId))!; + const username = user.username!; + const documents = (await server.stores.fsEntry.getEntryByPath( + `/${username}/Documents`, + ))!; + const scoped = await tokenActorFor( + userId, + `fs:${documents.uuid}:read`, + ); + + const { res, captured } = makeRes(); + await withActor(scoped, () => + controller.statEntry( + makeReq({ + body: { path: `/${username}/Documents` }, + actor: scoped, + }), + res, + ), + ); + expect((captured.body as { parentUid: unknown }).parentUid).toBe( + null, + ); + }); + + it('keeps it for a token that can list the home', async () => { + const { userId } = await makeUser(); + const user = (await server.stores.user.getById(userId))!; + const username = user.username!; + const home = (await server.stores.fsEntry.getEntryByPath( + `/${username}`, + ))!; + const scoped = await tokenActorFor(userId, `fs:${home.uuid}:list`); + + const { res, captured } = makeRes(); + await withActor(scoped, () => + controller.statEntry( + makeReq({ + body: { path: `/${username}/Documents` }, + actor: scoped, + }), + res, + ), + ); + expect((captured.body as { parentUid: unknown }).parentUid).toBe( + home.uuid, + ); + }); + + it('keeps it for a session actor', async () => { + const { actor } = await makeUser(); + const username = actor.user!.username!; + const home = (await server.stores.fsEntry.getEntryByPath( + `/${username}`, + ))!; + + const { res, captured } = makeRes(); + await withActor(actor, () => + controller.statEntry( + makeReq({ + body: { path: `/${username}/Documents` }, + actor, + }), + res, + ), + ); + expect((captured.body as { parentUid: unknown }).parentUid).toBe( + home.uuid, + ); + }); + }); }); // ── /readdir (readdirEntries) ─────────────────────────────────────── diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index 03bd8d97d..bbd47fd33 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -25,6 +25,7 @@ import type { Actor } from '../../core/actor.js'; import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; import { Controller, Get, Post } from '../../core/http/decorators.js'; +import { clientParentUid } from '../../services/fs/rootListing.js'; import { expandTildePath, isOwnersTrash, @@ -1031,7 +1032,7 @@ export class FSController extends PuterController { legacyCode: 'too_many_requests', }); } - const [subtreeSize, suggestedApps, shareFlags, shares] = + const [subtreeSize, suggestedApps, shareFlags, shares, parentUid] = await Promise.all([ entry.isDir && wantsSize ? this.services.fs.getSubtreeSize(userId, entry.path) @@ -1046,11 +1047,21 @@ export class FSController extends PuterController { entry.uuid, ) : undefined, + clientParentUid( + actor, + entry, + this.services.acl, + this.stores.permission, + ), ]); entry.suggestedApps = suggestedApps; res.json({ - ...this.#toClientEntry(entry, shareFlags.get(entry.uuid) ?? null), + ...this.#toClientEntry( + entry, + shareFlags.get(entry.uuid) ?? null, + parentUid, + ), ...(subtreeSize !== undefined ? { size: subtreeSize } : {}), ...(shares !== undefined ? { shares } : {}), }); @@ -1063,8 +1074,16 @@ export class FSController extends PuterController { * callers who only hold `see`/`list` on the entry — a share recipient, or * (with public folders enabled) any authenticated user. The legacy read * path already curates its output; this does the same for the v2 routes. + * + * `parentUid` is `entry.parentUid` unless `parentUidOverride` is passed + * (even as `null`) — callers reading a scoped access token's home pass the + * result of `clientParentUid` to hide it there. */ - #toClientEntry(entry: FSEntry, isShared?: boolean | null): ClientFSEntry { + #toClientEntry( + entry: FSEntry, + isShared?: boolean | null, + parentUidOverride?: string | null, + ): ClientFSEntry { // Allowlist, not a denylist: a denylist silently ships every column // added to `fsentries` later. Omits the numeric primary keys (`id`, // `parentId`, `associatedAppId`), the storage columns, the owning @@ -1076,7 +1095,10 @@ export class FSController extends PuterController { return { uuid: entry.uuid, uid: entry.uid ?? entry.uuid, - parentUid: entry.parentUid ?? null, + parentUid: + parentUidOverride !== undefined + ? parentUidOverride + : (entry.parentUid ?? null), path: maskEntryPath(entry), name: entry.name, isDir: entry.isDir, @@ -1340,6 +1362,12 @@ export class FSController extends PuterController { ...this.#toClientEntry( entry, shareFlags.get(entry.uuid) ?? null, + await clientParentUid( + actor, + entry, + this.services.acl, + this.stores.permission, + ), ), // Fields the client cannot derive on its own. type: fsEntryMimeType(entry), diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index bf492eb77..d90ccce32 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -550,6 +550,88 @@ describe('LegacyFSController.stat', () => { ); expect(plain.captured.body).toMatchObject({ name: 'share-budget' }); }); + + describe('parent_uid of a home child', () => { + 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('nulls it for a token scoped below the home', async () => { + const { userId } = await makeUser(); + const user = (await server.stores.user.getById(userId))!; + const username = user.username!; + const documents = (await server.stores.fsEntry.getEntryByPath( + `/${username}/Documents`, + ))!; + const scoped = await tokenActorFor( + userId, + `fs:${documents.uuid}:read`, + ); + + const { res, captured } = makeRes(); + await withActor(scoped, () => + controller.stat( + makeReq({ + body: { path: `/${username}/Documents` }, + actor: scoped, + }), + res, + ), + ); + const body = captured.body as Record; + expect(body.parent_uid).toBeNull(); + expect(body.parent_id).toBeNull(); + }); + + it('keeps it for a token that can list the home', async () => { + const { userId } = await makeUser(); + const user = (await server.stores.user.getById(userId))!; + const username = user.username!; + const home = (await server.stores.fsEntry.getEntryByPath( + `/${username}`, + ))!; + const scoped = await tokenActorFor(userId, `fs:${home.uuid}:list`); + + const { res, captured } = makeRes(); + await withActor(scoped, () => + controller.stat( + makeReq({ + body: { path: `/${username}/Documents` }, + actor: scoped, + }), + res, + ), + ); + const body = captured.body as Record; + expect(body.parent_uid).toBe(home.uuid); + }); + + it('keeps it for a session actor', async () => { + const { actor } = await makeUser(); + const username = actor.user!.username!; + const home = (await server.stores.fsEntry.getEntryByPath( + `/${username}`, + ))!; + + const { res, captured } = makeRes(); + await withActor(actor, () => + controller.stat( + makeReq({ + body: { path: `/${username}/Documents` }, + actor, + }), + res, + ), + ); + const body = captured.body as Record; + expect(body.parent_uid).toBe(home.uuid); + }); + }); }); describe('LegacyFSController.delete', () => { diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 5a64d8c18..c29eccf95 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -57,6 +57,7 @@ import { splitParentAndName, } from '../../services/fs/resolveNode.js'; import { maskEntryPath } from '../../services/fs/sharePathMask.js'; +import { clientParentUid } from '../../services/fs/rootListing.js'; import { buildHostedBackingDenial, hostedIndexUrlBackingIsUnavailable, @@ -510,11 +511,18 @@ export class LegacyFSController extends PuterController { 'see', ); - const [suggestedApps, appsById, shareFlags] = await Promise.all([ - this.services.suggestedApps.getSuggestedApps(entry), - loadLegacyAssociatedApps(this.stores.app, [entry]), - this.services.share.shareFlags(actor, [entry]), - ]); + const [suggestedApps, appsById, shareFlags, parentUid] = + await Promise.all([ + this.services.suggestedApps.getSuggestedApps(entry), + loadLegacyAssociatedApps(this.stores.app, [entry]), + this.services.share.shareFlags(actor, [entry]), + clientParentUid( + actor, + entry, + this.services.acl, + this.stores.permission, + ), + ]); entry.suggestedApps = suggestedApps; const shaped = await toLegacyEntry(this.clients.event, entry, { @@ -526,6 +534,7 @@ export class LegacyFSController extends PuterController { }, appsById, isShared: shareFlags.get(entry.uuid) ?? null, + parentUid, }); // Optional hydrations: diff --git a/src/backend/controllers/fs/legacyFsHelpers.test.ts b/src/backend/controllers/fs/legacyFsHelpers.test.ts index 51021ec6a..08015b548 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.test.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.test.ts @@ -412,6 +412,23 @@ describe('toLegacyEntry', () => { ); expect(shaped.associated_app).toBeNull(); }); + + it('uses the parentUid override, including null, over the entry’s own', async () => { + const hidden = await toLegacyEntry(undefined, baseEntry(), { + parentUid: null, + }); + expect(hidden.parent_id).toBeNull(); + expect(hidden.parent_uid).toBeNull(); + + const overridden = await toLegacyEntry(undefined, baseEntry(), { + parentUid: 'other-parent', + }); + expect(overridden.parent_id).toBe('other-parent'); + expect(overridden.parent_uid).toBe('other-parent'); + + const defaulted = await toLegacyEntry(undefined, baseEntry()); + expect(defaulted.parent_uid).toBe('parent-1'); + }); }); describe('loadLegacyAssociatedApps short-circuit', () => { diff --git a/src/backend/controllers/fs/legacyFsHelpers.ts b/src/backend/controllers/fs/legacyFsHelpers.ts index d508ad2a1..b5dbcec19 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.ts @@ -464,7 +464,9 @@ export async function signEntryThumbnail( * URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate `is_empty` * (directories) and `owner` — both are required fields per the legacy stat * contract but need extra DB lookups. Pass `appsById` (built via - * `loadLegacyAssociatedApps`) to populate `associated_app`. + * `loadLegacyAssociatedApps`) to populate `associated_app`. Pass `parentUid` + * (even as `null`, via `clientParentUid`) to override `parent_id`/`parent_uid` + * — callers reading a scoped access token's home use it to hide the uuid. */ export async function toLegacyEntry( eventClient: EventClient | undefined, @@ -479,6 +481,7 @@ export async function toLegacyEntry( isShared?: boolean | null; /** Skip the mask: the mask is the actor's, and only they can read it. */ forOwner?: boolean; + parentUid?: string | null; } = {}, ): Promise> { // Someone else's entry is published under its masked path; the owner's @@ -491,12 +494,17 @@ export async function toLegacyEntry( const appdata_app = pathComponents[2] === 'AppData' ? pathComponents[3] : undefined; + const parentUid = + opts.parentUid !== undefined + ? opts.parentUid + : (entry.parentUid ?? null); + const response: Record = { id: entry.uuid, uid: entry.uuid, uuid: entry.uuid, - parent_id: entry.parentUid, - parent_uid: entry.parentUid, + parent_id: parentUid, + parent_uid: parentUid, path: publishedPath, dirname, dirpath: dirname, diff --git a/src/backend/services/fs/rootListing.test.ts b/src/backend/services/fs/rootListing.test.ts index 276660728..83e41e9c3 100644 --- a/src/backend/services/fs/rootListing.test.ts +++ b/src/backend/services/fs/rootListing.test.ts @@ -26,7 +26,7 @@ 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'; +import { clientParentUid, listRootEntries } from './rootListing.js'; let server: PuterServer; let fsEntryStore: FSEntryStore; @@ -267,3 +267,82 @@ describe('listRootEntries', () => { ).resolves.toEqual([]); }); }); + +describe('clientParentUid', () => { + const parentUidFor = ( + actor: Actor, + entry: { path: string; parentUid: string | null }, + ) => + clientParentUid( + actor, + entry, + server.services.acl, + server.stores.permission, + ); + + it('nulls a home child’s parent for a token scoped below the home', async () => { + const user = await makeUser(); + const documents = (await fsEntryStore.getEntryByPath( + `/${user.username}/Documents`, + ))!; + const actor = await tokenActorFor(user.userId, [ + `fs:${documents.uuid}:read`, + ]); + + await expect(parentUidFor(actor, documents)).resolves.toBeNull(); + }); + + it('keeps it for a token that can list the home', async () => { + const user = await makeUser(); + const home = (await fsEntryStore.getEntryByPath(`/${user.username}`))!; + const documents = (await fsEntryStore.getEntryByPath( + `/${user.username}/Documents`, + ))!; + const actor = await tokenActorFor(user.userId, [ + `fs:${home.uuid}:list`, + ]); + + await expect(parentUidFor(actor, documents)).resolves.toBe(home.uuid); + }); + + it('keeps it for a session or a full-access token', async () => { + const user = await makeUser(); + const documents = (await fsEntryStore.getEntryByPath( + `/${user.username}/Documents`, + ))!; + const fullAccess = await tokenActorFor(user.userId, [FULL_API_ACCESS]); + + for (const actor of [user.actor, fullAccess]) { + await expect(parentUidFor(actor, documents)).resolves.toBe( + documents.parentUid, + ); + } + }); + + it('does not look up permissions for an entry that is not a direct home child', async () => { + const user = await makeUser(); + const documents = (await fsEntryStore.getEntryByPath( + `/${user.username}/Documents`, + ))!; + const nested = { + path: `/${user.username}/Documents/nested`, + parentUid: documents.uuid, + }; + const actor = await tokenActorFor(user.userId, [ + `fs:${documents.uuid}:read`, + ]); + const hasAny = vi.spyOn( + server.stores.permission, + 'hasAnyAccessTokenPerm', + ); + + try { + await expect(parentUidFor(actor, nested)).resolves.toBe( + documents.uuid, + ); + expect(hasAny).not.toHaveBeenCalled(); + } finally { + hasAny.mockRestore(); + } + }); +}); diff --git a/src/backend/services/fs/rootListing.ts b/src/backend/services/fs/rootListing.ts index 88527ec46..6a1315e71 100644 --- a/src/backend/services/fs/rootListing.ts +++ b/src/backend/services/fs/rootListing.ts @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import { posix as pathPosix } from 'node:path'; import { isAccessTokenActor, isAccountContext, @@ -115,3 +116,36 @@ export async function rootShowsHome( aclService.permissionsFor(home.uuid, mode), ); } + +/** + * The `parentUid` to publish for `entry`. Nulled when the parent is the + * issuer's own home and the access token behind this response can't list it — + * otherwise the uuid alone would name a home that `rootShowsHome` keeps out of + * root listings. A direct child of anything else returns its own `parentUid` + * without a lookup. + */ +export async function clientParentUid( + actor: Actor, + entry: Pick, + aclService: ACLService, + permissionStore: PermissionStore, +): Promise { + const parentUid = entry.parentUid ?? null; + if ( + !parentUid || + !isAccessTokenActor(actor) || + isAccountContext(actor) || + !actor.user.username || + pathPosix.dirname(entry.path) !== `/${actor.user.username}` + ) { + return parentUid; + } + const visible = await rootShowsHome( + actor, + { uuid: parentUid }, + 'list', + aclService, + permissionStore, + ); + return visible ? parentUid : null; +}