fix(share): SEO route must not leak a restricted page's title (#159)
`ShareSeoController.getShare` resolved the inherited share with the RAW `getShareForPage`, which does NOT run the restricted-ancestor gate. So for a page shared with includeSubPages whose descendant is permission-restricted, the SEO route served that descendant's real title in <title>/og:title/twitter:title to anonymous visitors and crawlers — even though the content API returns 404 for it (red-team finding #3). Funnel the SEO path through the canonical `resolveReadableSharePage` boundary (the single place that checks `hasRestrictedAncestor`): a non-readable page now serves the plain SPA index with no meta. Also honour `isSharingAllowed` — a share whose workspace/space sharing toggle was flipped off after creation no longer leaks its title via SEO. Title comes from the server-resolved page; `buildShareMetaHtml` already emits robots=noindex when the share opted out of indexing. Tests (controller routing, fs spied at call time so bcrypt's native loader is untouched): non-readable page => plain index, no title; sharing-disabled => plain index; readable+indexing => title + og:title, no noindex; readable+no- indexing => noindex. Asserts getShareForPage is never called by the SEO path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,19 +63,38 @@ export class ShareSeoController {
|
||||
|
||||
const pageId = this.extractPageSlugId(pageSlug);
|
||||
|
||||
const share = await this.shareService.getShareForPage(
|
||||
// Funnel through the canonical readable-share boundary (NOT the raw
|
||||
// getShareForPage) so the restricted-ancestor gate runs: a permission-
|
||||
// restricted descendant of an includeSubPages share must NOT leak its
|
||||
// title to anonymous visitors / crawlers (red-team finding #3). null =>
|
||||
// not publicly readable => serve the plain SPA index with no meta.
|
||||
const resolved = await this.shareService.resolveReadableSharePage(
|
||||
undefined,
|
||||
pageId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!share) {
|
||||
if (!resolved) {
|
||||
return this.sendIndex(indexFilePath, res);
|
||||
}
|
||||
|
||||
// Honour a workspace/space-level sharing toggle flipped off AFTER this
|
||||
// share was created: the content API gates on isSharingAllowed, so the SEO
|
||||
// path must too or it keeps serving the title for a no-longer-shared page.
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspace.id,
|
||||
resolved.share.spaceId,
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
return this.sendIndex(indexFilePath, res);
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(indexFilePath, 'utf8');
|
||||
// Title of the PAGE being viewed (server-resolved), and noindex unless the
|
||||
// share opted into search indexing (buildShareMetaHtml injects it).
|
||||
let transformedHtml = buildShareMetaHtml(html, {
|
||||
title: share?.sharedPage.title,
|
||||
searchIndexing: share.searchIndexing,
|
||||
title: resolved.page.title,
|
||||
searchIndexing: resolved.share.searchIndexing,
|
||||
});
|
||||
|
||||
// Deliberate same-origin tracker surface: this is the ONE place where an
|
||||
|
||||
Reference in New Issue
Block a user