Files
gitmost/apps/server/src/core/share/share-alias.service.ts
T
agent_coder 4109b2ef7f fix(security): share-alias reassign 409 — гейт раскрытия по view-праву (доведение #495 к.4)
Коммит 4 закрыл утечку currentPageId на анонимном /availability, но
эквивалентная (и более широкая) дыра оставалась на POST /aliases/set (reassign):
при занятом алиасе и confirmReassign=false 409 отдавал currentPageId И
currentPageTitle ЦЕЛЕВОЙ страницы, а контроллер гейтил только validateCanEdit на
ИСХОДНОЙ странице. Любой участник с одной редактируемой+расшаренной страницей мог
перебирать имена алиасов и мапить их на (id, title) чужих страниц без права
просмотра — тот же класс перечисления, плюс ещё и заголовок.

Фикс: setAlias теперь гейтит раскрытие. currentPageId НЕ отдаётся никогда
(клиент им не пользуется, это перечислимая идентичность). currentPageTitle —
только если validateCanView(целевая, user) проходит; иначе голый «занят» (клиент
и так показывает generic confirm-модалку без заголовка — UX не ломается).
Гейт живёт в сервисе, где строится раскрытие (PageAccessService — @Global, без
цикла); контроллер прокидывает user. Поправлен неточный коммент checkAvailability.

Тесты: viewer → 409 с title, БЕЗ id; не-viewer → 409 без title и без id.
Mutation-verify: вернул утечку (id + безусловный title) → оба теста краснеют.
Контроллер-спек и int-spec обновлены под новую сигнатуру; tsc чист.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00

329 lines
13 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { ShareService } from './share.service';
import { PageAccessService } from '../page/page-access/page-access.service';
import { Page, ShareAlias, User } from '@docmost/db/types/entity.types';
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
executeTx,
isUniqueViolation,
violatedConstraint,
} from '@docmost/db/utils';
import { NoResultError } from 'kysely';
/**
* Unique index name from the share_aliases migrations whose violation we map to
* a DISTINCT, non-misleading outcome:
* - PAGE_ID: partial `(workspace_id, page_id) WHERE page_id IS NOT NULL`
* -> a concurrent writer already gave THIS page an alias.
* The `(workspace_id, alias)` index (the vanity NAME being taken) needs no
* constant: it is the default "Alias already taken" mapping.
*/
const UNIQUE_PAGE_ID_INDEX = 'share_aliases_workspace_id_page_id_unique';
export interface ResolvedAliasTarget {
share: NonNullable<
Awaited<ReturnType<ShareService['resolveReadableSharePage']>>
>['share'];
page: Page;
}
@Injectable()
export class ShareAliasService {
private readonly logger = new Logger(ShareAliasService.name);
constructor(
private readonly shareAliasRepo: ShareAliasRepo,
private readonly pageRepo: PageRepo,
private readonly shareService: ShareService,
private readonly pageAccessService: PageAccessService,
@InjectKysely() private readonly db: KyselyDB,
) {}
/**
* Create, RENAME or retarget a page's vanity alias. INVARIANT: a page has
* EXACTLY ONE custom address. The alias name is workspace-scoped:
* - name free, page has no alias yet -> INSERT a new pointer
* - name free, page already has one -> RENAME that row in place (the slug
* edit, e.g. `te` -> `ted`); we never spawn a second row, so no orphan
* `/l/<old>` link survives
* - name already points at pageId -> no-op (idempotent)
* - name points at ANOTHER page -> the "swap". Without confirmReassign
* we throw 409 so the client can confirm. SECURITY (#495): the 409 reveals
* the current target's title ONLY when `user` may VIEW that page, and never
* its id — otherwise any member with one editable+shared page could iterate
* alias names with confirmReassign=false and map them to (id, title) of
* pages they cannot see. With confirmReassign we UPDATE the single row's
* page_id (every /l/<alias> link follows the 302 to the new page instantly
* — no stale cache).
*
* To keep the invariant self-healing we DELETE every other alias row still
* pointing at this page (a legacy duplicate, or the target page's own former
* alias during a swap). The whole thing runs in one transaction. Because the
* `(workspace_id, page_id)` unique index is NON-deferrable (checked at the end
* of each statement), the swap branch DELETEs the target page's existing row
* BEFORE retargeting, so the page is never transiently carried by two rows;
* the other branches self-heal AFTER their write. Either way the page never
* ends a statement with duplicate rows.
*
* Caller is responsible for authorizing the page (edit rights + public
* readability); this method owns only the alias-name semantics.
*/
async setAlias(opts: {
workspaceId: string;
pageId: string;
creatorId: string;
alias: string;
confirmReassign?: boolean;
// The requesting user — used ONLY to gate whether the reassign 409 may reveal
// the current target page's title (view-permission check). Not an authz gate
// for the write itself (the controller already validated edit on `pageId`).
user: User;
}): Promise<ShareAlias> {
const { workspaceId, pageId, creatorId, confirmReassign, user } = opts;
const alias = normalizeShareAlias(opts.alias);
if (!isValidShareAlias(alias)) {
throw new BadRequestException(
'Invalid alias. Use 2-60 lowercase letters, digits and hyphens.',
);
}
try {
return await executeTx(this.db, async (trx) => {
const byName = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
trx,
);
// The name is occupied by a DIFFERENT (or dangling) target page.
if (byName && byName.pageId !== pageId) {
if (!confirmReassign) {
// SECURITY (#495): only disclose the current target's TITLE, and only
// when the requester may VIEW that page. Never disclose its id (the
// client's confirm-reassign UX doesn't use it, and it is an enumerable
// identity). A member with one editable+shared page must NOT be able to
// iterate alias names and map them to (id, title) of pages they cannot
// see. When view is denied (or the alias is dangling) the 409 is the
// bare "occupied" fact — the client still shows a generic confirm modal.
const currentPage = byName.pageId
? await this.pageRepo.findById(byName.pageId)
: null;
let currentPageTitle: string | null = null;
if (currentPage) {
try {
await this.pageAccessService.validateCanView(currentPage, user);
currentPageTitle = currentPage.title ?? null;
} catch {
// No view permission on the target -> do not reveal its title.
currentPageTitle = null;
}
}
throw new ConflictException({
message: 'Alias already in use',
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageTitle,
});
}
// Confirmed swap. ORDER MATTERS: the partial unique index on
// `(workspace_id, page_id)` is NON-deferrable, so it is checked at the
// end of EVERY statement. If we retargeted `byName` onto `pageId`
// first while `pageId` still had its OWN alias row, there would
// momentarily be two rows with this page_id -> immediate 23505 and a
// rolled-back tx (a misleading "Alias already taken"). So we FIRST drop
// the target page's existing alias row(s), THEN retarget. `byName.id`
// still points at its old page here, so excluding it via `keepId` is
// harmless; after the retarget it is the page's only row, so no
// trailing self-heal is needed.
await this.shareAliasRepo.deleteOthersForPage(
pageId,
byName.id,
workspaceId,
trx,
);
return await this.shareAliasRepo.updatePageId(
byName.id,
pageId,
workspaceId,
trx,
);
}
// The name is FREE, or already points at THIS page. Ensure the page has
// a single row carrying this name: rename its current one, or insert.
const current =
byName ??
(await this.shareAliasRepo.findByPageId(pageId, workspaceId, trx));
let row: ShareAlias;
if (current) {
row =
current.alias === alias
? current // same-name no-op
: await this.shareAliasRepo.updateAlias(
current.id,
alias,
workspaceId,
trx,
);
} else {
row = await this.shareAliasRepo.insert(
{ workspaceId, alias, pageId, creatorId },
trx,
);
}
// Self-heal: a page keeps EXACTLY ONE custom address.
await this.shareAliasRepo.deleteOthersForPage(
pageId,
row.id,
workspaceId,
trx,
);
return row;
});
} catch (err: any) {
if (
err instanceof ConflictException ||
err instanceof BadRequestException
) {
throw err;
}
// The row we read was deleted (concurrent `removeAlias`) before our UPDATE
// matched it, so `executeTakeFirstOrThrow` found no row. Surface a
// retryable conflict instead of a 200-without-alias (swap branch) or a
// generic 400 from dereferencing `undefined.id` (rename branch).
if (err instanceof NoResultError) {
this.logger.warn(
'share alias update matched no row (concurrent-delete race)',
);
throw new ConflictException({
message: 'The address changed concurrently, please retry',
code: 'ALIAS_PAGE_RACE',
});
}
// A unique index fired. Which one decides the message — always log the
// constraint so the race is diagnosable.
if (isUniqueViolation(err)) {
const constraint = violatedConstraint(err);
this.logger.warn(
`share alias unique violation on ${constraint ?? '<unknown>'}`,
);
// `(workspace_id, page_id)`: a concurrent request already gave this page
// an alias. The page still has exactly one custom address (the racing
// writer's), so this is not a user-facing name clash — surface a
// distinct, non-misleading message instead of "Alias already taken".
if (constraint === UNIQUE_PAGE_ID_INDEX) {
throw new ConflictException({
message: 'This page is being given an address by another request',
code: 'ALIAS_PAGE_RACE',
});
}
// `(workspace_id, alias)` or any other/unknown unique index: treat as
// the vanity name being claimed first.
throw new ConflictException({ message: 'Alias already taken' });
}
this.logger.error(err);
throw new BadRequestException('Failed to set alias');
}
}
/** Free a vanity name (no history kept). */
async removeAlias(aliasId: string, workspaceId: string): Promise<void> {
await this.shareAliasRepo.delete(aliasId, workspaceId);
}
/** Debounced availability probe for the modal. */
async checkAvailability(
rawAlias: string,
workspaceId: string,
): Promise<{
alias: string;
valid: boolean;
available: boolean;
}> {
const alias = normalizeShareAlias(rawAlias);
if (!isValidShareAlias(alias)) {
return { alias, valid: false, available: false };
}
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
// SECURITY (#495): return ONLY the boolean availability. The previous shape
// leaked `currentPageId` — the id of whatever page the alias already targets —
// to ANY authenticated workspace member, with no view-permission check on that
// page. An attacker could enumerate alias names and map them to page ids they
// have no access to. The taken/free bit is all the "is this address free"
// probe needs. The reassign flow (setAlias 409) may surface the target's
// TITLE, but only behind a `validateCanView` on that page (see setAlias); it
// never returns the page id.
return {
alias,
valid: true,
available: !existing,
};
}
/** A single alias row scoped to the workspace, or undefined. */
getAliasById(
aliasId: string,
workspaceId: string,
): Promise<ShareAlias | undefined> {
return this.shareAliasRepo.findById(aliasId, workspaceId);
}
/** The alias currently targeting a page (modal display), or undefined. */
getAliasForPage(
pageId: string,
workspaceId: string,
): Promise<ShareAlias | undefined> {
return this.shareAliasRepo.findByPageId(pageId, workspaceId);
}
/**
* Resolve a vanity alias to the canonical, publicly-READABLE share page, or
* null. This re-runs the authoritative share boundary at request time (so a
* later-unshared / restricted / sharing-disabled target collapses to null and
* the caller serves the generic SPA 404 — no existence leak). The alias row
* itself is just a pointer; this is where access is actually decided.
*/
async resolveReadableTarget(
rawAlias: string,
workspaceId: string,
): Promise<ResolvedAliasTarget | null> {
const alias = normalizeShareAlias(rawAlias);
if (!isValidShareAlias(alias)) return null;
const aliasRow = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
// Unknown name or a dangling alias (target page deleted) -> not resolvable.
if (!aliasRow?.pageId) return null;
const resolved = await this.shareService.resolveReadableSharePage(
undefined,
aliasRow.pageId,
workspaceId,
);
if (!resolved) return null;
const sharingAllowed = await this.shareService.isSharingAllowed(
workspaceId,
resolved.share.spaceId,
);
if (!sharingAllowed) return null;
return resolved;
}
}