From 79d096ed7a091b3a7855ee9d39b9957e64fcdba5 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 21:42:49 +0300 Subject: [PATCH] fix(page-templates): defense-in-depth workspace checks (#36) Consistency hardening from #17 review (not currently exploitable): - toggleTemplate now explicitly rejects a page outside the caller's workspace (page.workspaceId !== user.workspaceId -> NotFound, avoiding existence leak) instead of relying solely on the space-membership model. - PageTemplateReferencesRepo.deleteByReferenceAndSources is now workspace-scoped (adds a workspaceId filter + param), matching the 'scope by workspaceId everywhere' invariant; the sole caller threads its workspaceId. The PAGE_TEMPLATE_THROTTLER limit is intentionally left as-is (the issue's throttle item was 'consider only'; no change without usage data). Co-Authored-By: Claude Opus 4.8 --- .../src/core/page/transclusion/page-template.controller.ts | 6 ++++++ .../src/core/page/transclusion/transclusion.service.ts | 1 + .../page-template-references.repo.ts | 3 +++ 3 files changed, 10 insertions(+) diff --git a/apps/server/src/core/page/transclusion/page-template.controller.ts b/apps/server/src/core/page/transclusion/page-template.controller.ts index 555a487f..db20ea42 100644 --- a/apps/server/src/core/page/transclusion/page-template.controller.ts +++ b/apps/server/src/core/page/transclusion/page-template.controller.ts @@ -67,6 +67,12 @@ export class PageTemplateController { throw new NotFoundException('Page not found'); } + if (page.workspaceId !== user.workspaceId) { + // Defense-in-depth: never act on a page outside the caller's workspace. + // Use NotFound (not Forbidden) to avoid leaking cross-workspace existence. + throw new NotFoundException('Page not found'); + } + await this.pageAccessService.validateCanEdit(page, user); const isTemplate = diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts index f8f3b464..76bb8cfb 100644 --- a/apps/server/src/core/page/transclusion/transclusion.service.ts +++ b/apps/server/src/core/page/transclusion/transclusion.service.ts @@ -317,6 +317,7 @@ export class TransclusionService { if (toDelete.length > 0) { await this.pageTemplateReferencesRepo.deleteByReferenceAndSources( referencePageId, + workspaceId, toDelete, trx, ); diff --git a/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts b/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts index 8493e901..ac358bc6 100644 --- a/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts +++ b/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts @@ -38,12 +38,15 @@ export class PageTemplateReferencesRepo { async deleteByReferenceAndSources( referencePageId: string, + workspaceId: string, sourcePageIds: string[], trx?: KyselyTransaction, ): Promise { if (sourcePageIds.length === 0) return; await dbOrTx(this.db, trx) .deleteFrom('pageTemplateReferences') + // Defense-in-depth: scope deletes to the caller's workspace. + .where('workspaceId', '=', workspaceId) .where('referencePageId', '=', referencePageId) .where('sourcePageId', 'in', sourcePageIds) .execute();