Files
gitmost/apps/server/src/core/share/share-seo.controller.ts
claude code agent 227 96fb737c9d 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>
2026-06-25 05:19:32 +03:00

142 lines
5.2 KiB
TypeScript

import { Controller, Get, Logger, Param, Req, Res } from '@nestjs/common';
import { ShareService } from './share.service';
import { FastifyReply, FastifyRequest } from 'fastify';
import { join } from 'path';
import * as fs from 'node:fs';
import { validate as isValidUUID } from 'uuid';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
import { injectTrackerHead } from './inject-tracker-head.util';
import { buildShareMetaHtml } from './share-seo.util';
@Controller('share')
export class ShareSeoController {
private readonly logger = new Logger(ShareSeoController.name);
constructor(
private readonly shareService: ShareService,
private workspaceRepo: WorkspaceRepo,
private environmentService: EnvironmentService,
) {}
/*
* add meta tags to publicly shared pages
*/
@Get([':shareId/p/:pageSlug', 'p/:pageSlug'])
async getShare(
@Res({ passthrough: false }) res: FastifyReply,
@Req() req: FastifyRequest,
@Param('shareId') shareId: string,
@Param('pageSlug') pageSlug: string,
) {
// Nestjs does not to apply middlewares to paths excluded from the global /api prefix
// https://github.com/nestjs/nest/issues/9124
// https://github.com/nestjs/nest/issues/11572
// https://github.com/nestjs/nest/issues/13401
// we have to duplicate the DomainMiddleware code here as a workaround
let workspace: Workspace = null;
if (this.environmentService.isSelfHosted()) {
workspace = await this.workspaceRepo.findFirst();
} else {
const header = req.raw.headers.host;
const subdomain = header.split('.')[0];
workspace = await this.workspaceRepo.findByHostname(subdomain);
}
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
if (fs.existsSync(clientDistPath)) {
const indexFilePath = join(clientDistPath, 'index.html');
if (!workspace) {
return this.sendIndex(indexFilePath, res);
}
const pageId = this.extractPageSlugId(pageSlug);
// 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 (!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: resolved.page.title,
searchIndexing: resolved.share.searchIndexing,
});
// Deliberate same-origin tracker surface: this is the ONE place where an
// admin-authored analytics/tracker snippet (settings.trackerHead) is
// injected verbatim into the page origin. It is admin-only (writable only
// via the admin-gated workspace settings) and applies to PUBLIC SHARE
// pages only. It is trusted content, so it is NOT escaped. The htmlEmbed
// block itself is sandboxed and is the safe surface for everyone else.
const trackerHead = (workspace?.settings as any)?.trackerHead;
const beforeInjection = transformedHtml;
transformedHtml = injectTrackerHead(transformedHtml, trackerHead);
if (
beforeInjection === transformedHtml &&
typeof trackerHead === 'string' &&
trackerHead.trim().length > 0
) {
// A non-empty snippet was configured but nothing was injected: the only
// reason injectTrackerHead leaves the html unchanged for a non-empty
// snippet is a missing </head> marker.
this.logger.warn(
'trackerHead is configured but no </head> marker was found in the share index HTML; tracker snippet was not injected.',
);
}
res.type('text/html').send(transformedHtml);
}
}
sendIndex(indexFilePath: string, res: FastifyReply) {
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
}
extractPageSlugId(slug: string): string {
if (!slug) {
return undefined;
}
if (isValidUUID(slug)) {
return slug;
}
const parts = slug.split('-');
return parts.length > 1 ? parts[parts.length - 1] : slug;
}
}