diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 74fcf9231..4b4f677d6 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -23,6 +23,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { Actor } from '../../core/actor.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; +import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import { AuthService } from './AuthService.js'; function createAuthService(): AuthService { @@ -1449,6 +1450,114 @@ describe('AuthService (integration)', () => { ), ).rejects.toMatchObject({ statusCode: 403 }); }); + + // Regression for the post-#1001 token-inval check: an + // app-under-user actor must be able to mint a token for a file + // inside its own AppData. Without the `app-owns-appdata` + // implicator the issuer-subset gate 403s these — even though + // ACLService already allows the equivalent fs.read via its own + // short-circuit. puter-js getReadURL is the canonical caller. + it('app-under-user actor can mint fs::read for a file inside its own AppData', async () => { + const user = await makeUser(); + await generateDefaultFsentries( + server.clients.db, + server.stores.user, + user, + ); + const appUid = `app-${uuidv4()}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [ + appUid, + `n-${appUid}`, + `t-${appUid}`, + `https://${appUid}.example/`, + user.id, + ], + ); + + const appDataPath = `/${user.username}/AppData/${appUid}`; + await server.services.fs.mkdir(user.id, { + path: appDataPath, + createMissingParents: true, + } as never); + const body = Buffer.from('hello'); + await server.services.fs.write(user.id, { + fileMetadata: { + path: `${appDataPath}/note.txt`, + size: body.byteLength, + contentType: 'text/plain', + }, + fileContent: body, + } as never); + const fileEntry = await server.stores.fsEntry.getEntryByPath( + `${appDataPath}/note.txt`, + ); + expect(fileEntry).not.toBeNull(); + + const appActor: Actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + app: { id: 0, uid: appUid }, + } as Actor; + + const jwt = await authService.createAccessToken(appActor, [ + [`fs:${fileEntry!.uuid}:read`], + ]); + expect(typeof jwt).toBe('string'); + }); + + // Negative side of the implicator: a file the user owns but + // that lives *outside* the app's AppData must still be + // rejected — the issuer-subset gate is the only thing + // preventing an authorized app from minting a token over + // arbitrary user-owned uuids. + it('app-under-user actor cannot mint fs::read for a user-owned file outside its AppData', async () => { + const user = await makeUser(); + await generateDefaultFsentries( + server.clients.db, + server.stores.user, + user, + ); + const appUid = `app-${uuidv4()}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [ + appUid, + `n-${appUid}`, + `t-${appUid}`, + `https://${appUid}.example/`, + user.id, + ], + ); + + const body = Buffer.from('secret'); + await server.services.fs.write(user.id, { + fileMetadata: { + path: `/${user.username}/Documents/secret.txt`, + size: body.byteLength, + contentType: 'text/plain', + }, + fileContent: body, + } as never); + const fileEntry = await server.stores.fsEntry.getEntryByPath( + `/${user.username}/Documents/secret.txt`, + ); + expect(fileEntry).not.toBeNull(); + + const appActor: Actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + app: { id: 0, uid: appUid }, + } as Actor; + + await expect( + authService.createAccessToken(appActor, [ + [`fs:${fileEntry!.uuid}:read`], + ]), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'forbidden', + }); + }); }); describe('private-asset / public hosted-actor tokens', () => { diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index b84c8ad72..6b7582a9f 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -206,6 +206,51 @@ export class FSService extends PuterService { }, }); + // -- app-owns-appdata ----------------------------------------- + // Mirror of the ACLService short-circuit at ACLService.check: + // an app-under-user actor implicitly holds fs::* on any + // entry inside its own //AppData/ subtree. + // ACL paths (fs.read etc.) already accept these via that + // short-circuit, but permissionService.checkMany — used by + // createAccessToken's issuer-subset gate — bypasses ACL, so + // without this implicator an app can fs.read its appdata but + // can't mint a token for the same fs::read it just read. + permissions.registerImplicator({ + id: 'app-owns-appdata', + shortcut: true, + matches: (permission: string): boolean => { + return ( + permission.startsWith('fs:') || + permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`) || + permission.startsWith( + `${MANAGE_PERM_PREFIX}:${MANAGE_PERM_PREFIX}:fs:`, + ) + ); + }, + check: async ({ actor, permission }): Promise => { + if (!actor.app || actor.accessToken) return undefined; + const username = actor.user?.username; + const appUid = actor.app.uid; + if (!username || !appUid) return undefined; + + const stripped = permission.replaceAll( + `${MANAGE_PERM_PREFIX}:`, + '', + ); + const uid = PermissionUtil.split(stripped)[1]; + if (!uid) return undefined; + + const entry = await fsEntryStore.getEntryByUuid(uid); + if (!entry) return undefined; + + const root = `/${username}/AppData/${appUid}`; + if (entry.path === root || entry.path.startsWith(`${root}/`)) { + return {}; + } + return undefined; + }, + }); + // -- fs-access-levels exploder ---------------------------------- // `fs:UUID:see` implies `[list, read, write, manage:fs:UUID]`. // ACLService.check already expands the same-family chain