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 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 21:42:49 +03:00
parent a15cccf557
commit 79d096ed7a
3 changed files with 10 additions and 0 deletions

View File

@@ -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 =

View File

@@ -317,6 +317,7 @@ export class TransclusionService {
if (toDelete.length > 0) {
await this.pageTemplateReferencesRepo.deleteByReferenceAndSources(
referencePageId,
workspaceId,
toDelete,
trx,
);

View File

@@ -38,12 +38,15 @@ export class PageTemplateReferencesRepo {
async deleteByReferenceAndSources(
referencePageId: string,
workspaceId: string,
sourcePageIds: string[],
trx?: KyselyTransaction,
): Promise<void> {
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();