refactor(share): single resolveReadableSharePage for the share access boundary (#92)

The '(shareId,pageId) -> usable non-restricted page in THIS share' boundary was
written as 3 must-be-identical async sequences. They weren't: the chat funnel
omitted an explicit page.deletedAt check (latently safe via getShareForPage's
CTE) and layered isSharingAllowed separately. Add ShareService.resolveReadable-
SharePage(shareId,pageId,workspaceId) running the single canonical sequence
(getShareForPage -> id match (skipped when null) -> findById -> !deletedAt ->
!hasRestrictedAncestor) returning {share,page}|null; getSharedPage, the funnel,
and the getSharePage tool all use it. hasRestrictedAncestor now lives in the one
method no caller can skip; the funnel still returns uniform 404s and keeps
isSharingAllowed. Adds a direct security-invariant test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 04:04:09 +03:00
parent 3953ecdb17
commit 3f46496192
7 changed files with 347 additions and 178 deletions

View File

@@ -128,28 +128,82 @@ export class ShareService {
}
}
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
const share = await this.getShareForPage(dto.pageId, workspaceId);
/**
* THE share access boundary in ONE place.
*
* Answers exactly: "does this (shareId, pageId) pair resolve to a usable,
* non-restricted, live page WITHIN this share?" Returns the resolved
* `{ share, page }` on success, or `null` on ANY failure (share not found /
* wrong workspace / out-of-tree page / share-id mismatch / missing /
* soft-deleted / restricted ancestor).
*
* This is the single canonical sequence that every public-share read path
* must funnel through, so no path can skip a check (most importantly the
* restricted-ancestor gate, which `getShareForPage` does NOT perform on its
* own). The checks run in this fixed order:
* 1. getShareForPage(pageId, workspaceId) — page reachable in this ws?
* 2. share.id === shareId — and it is THIS share?
* (pass `null`/`undefined` shareId to skip the match when the caller has
* no independent requested shareId — getSharedPage resolves the share
* FROM the page, so there is nothing to cross-check.)
* 3. pageRepo.findById(pageId, ...) — page row (+ content/creator)
* 4. !page.deletedAt — live (defense in depth:
* getShareForPage already excludes deleted anchors)
* 5. !hasRestrictedAncestor(page.id) — not a restricted descendant
*
* `isSharingAllowed` is intentionally NOT part of this boundary: it is an
* orthogonal workspace/space toggle that each call-site layers separately
* (share.controller after getSharedPage; the assistant funnel as its own
* gate). Folding it in here would silently change those call-sites' grading.
*/
async resolveReadableSharePage(
shareId: string | null | undefined,
pageId: string,
workspaceId: string,
opts?: { includeCreator?: boolean },
): Promise<{
share: NonNullable<Awaited<ReturnType<ShareService['getShareForPage']>>>;
page: Page;
} | null> {
const share = await this.getShareForPage(pageId, workspaceId);
if (!share) return null;
if (!share) {
throw new NotFoundException('Shared page not found');
}
// Only ever an equality check against the server-resolved share id; an
// attacker-supplied shareId can never widen access. Skipped when the caller
// passes no shareId (it resolved the share from the page itself).
if (shareId != null && share.id !== shareId) return null;
const page = await this.pageRepo.findById(dto.pageId, {
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
includeCreator: true,
includeCreator: opts?.includeCreator ?? false,
});
if (!page || page.deletedAt) return null;
if (!page || page.deletedAt) {
// Restricted descendants are hidden from the public view even inside an
// includeSubPages share; getShareForPage does NOT exclude them.
if (await this.pagePermissionRepo.hasRestrictedAncestor(page.id)) {
return null;
}
return { share, page };
}
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
// Resolve via the single canonical boundary. There is no independent
// requested shareId here (the share is resolved FROM the page), so no
// share-id match is performed.
const resolved = await this.resolveReadableSharePage(
null,
dto.pageId,
workspaceId,
{ includeCreator: true },
);
if (!resolved) {
throw new NotFoundException('Shared page not found');
}
// Block access to restricted pages
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
if (isRestricted) {
throw new NotFoundException('Shared page not found');
}
const { share, page } = resolved;
page.content = await this.updatePublicAttachments(page);