From 1e3b704ea8606487692e5d6700f360dba6016007 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:37:23 -0500 Subject: [PATCH] fix(data-access): add guard for app actors --- .../src/modules/data-access/AppService.js | 25 +++++++++ .../modules/data-access/AppService.test.js | 56 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index f8536dd98..1995040d9 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -494,6 +494,26 @@ export default class AppService extends BaseService { return result.insertId; } + async #check_app_owner_permission (old_app, actor) { + // Check if app has write permission to all user's apps + const svc_permission = this.services.get('permission'); + const user = actor.type.user; + const perm = `es:app:${user.uuid}:write`; + const can_write_any = await svc_permission.check(actor, perm); + if ( can_write_any ) { + return; + } + + // Otherwise verify the app owns this entity + const app = actor.type.app; + const app_owner = old_app.app_owner; + const app_owner_uid = app_owner?.uid; + + if ( ! app_owner_uid || app_owner_uid !== app.uid ) { + throw APIError.create('forbidden'); + } + } + async #update ({ object, id, options }) { const old_app = await this.#read({ uid: object.uid, @@ -515,6 +535,11 @@ export default class AppService extends BaseService { // Check owner permission (WriteByOwnerOnlyES behavior) await this.#check_owner_permission(old_app); + // If actor is AppUnderUserActorType, check app_owner (AppLimitedES behavior) + if ( actor.type instanceof AppUnderUserActorType ) { + await this.#check_app_owner_permission(old_app, actor); + } + // Remove protected/read_only fields from the update (ValidationES behavior) { object = { ...object }; diff --git a/src/backend/src/modules/data-access/AppService.test.js b/src/backend/src/modules/data-access/AppService.test.js index 794ad3a67..b09105506 100644 --- a/src/backend/src/modules/data-access/AppService.test.js +++ b/src/backend/src/modules/data-access/AppService.test.js @@ -1195,6 +1195,62 @@ describe('AppService', () => { expect.arrayContaining(['https://mysite.puter.site']) ); }); + + it('should throw forbidden when app actor does not own the entity (AppLimitedES behavior)', async () => { + // App actor trying to update an app it didn't create + setupContextForWrite(createMockAppUnderUserActor(1, 999)); + mockDb.read.mockResolvedValue([createMockAppRow({ + owner_user_id: 1, + app_owner_uid: 'different-app-uid', + })]); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + + await expect(crudQ.update.call(appService, { + object: { uid: 'app-uid-123', title: 'Hacked Title' }, + })).rejects.toThrow(); + }); + + it('should allow app actor to update entity it owns (AppLimitedES behavior)', async () => { + // App actor updating an app it created + const actor = createMockAppUnderUserActor(1, 100); + actor.type.app.uid = 'creator-app-uid'; + setupContextForWrite(actor); + mockDb.read.mockResolvedValue([createMockAppRow({ + owner_user_id: 1, + app_owner_uid: 'creator-app-uid', + })]); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.update.call(appService, { + object: { uid: 'app-uid-123', title: 'Updated by App' }, + }); + + expect(mockDbWrite.write).toHaveBeenCalledWith( + expect.stringContaining('UPDATE apps SET'), + expect.arrayContaining(['Updated by App']) + ); + }); + + it('should allow app actor with write permission to update any entity (AppLimitedES behavior)', async () => { + setupContextForWrite(createMockAppUnderUserActor(1, 999)); + mockDb.read.mockResolvedValue([createMockAppRow({ + owner_user_id: 1, + app_owner_uid: 'different-app-uid', + })]); + // Grant write permission + mockPermissionService.check.mockResolvedValue(true); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.update.call(appService, { + object: { uid: 'app-uid-123', title: 'Admin Update' }, + }); + + expect(mockDbWrite.write).toHaveBeenCalledWith( + expect.stringContaining('UPDATE apps SET'), + expect.arrayContaining(['Admin Update']) + ); + }); }); describe('#upsert', () => {