[security][audit] Red-team аудит (85db20f9..HEAD): потеря страниц, утечка title шара, ИИ правит не ту страницу, кап стоимости, CI #159
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Red-team аудит диапазона
85db20f9..HEAD(30 коммитов)Аудит: 6 read-only измерений параллельно; 7 ключевых утверждений перепроверены против
исходников (5 подтверждено, 1 — по механизму, 1 опровергнуто). Полный отчёт —
локально в
red-team-report.md(на веткеdevelop, ещё не закоммичен).Приоритеты (severity × вероятность × простота проверки)
currentPage(id+title) не валидируетсяpatchNode/deleteNodeбьют по всем узлам с одинаковымattrs.idupdatePageJson: split-brain title/тело при падении записи телаP1 — Потеря данных при обычной работе (самое заметное пользователям)
1. Перемещение в незагруженный родитель молча удаляет стран��цу у авторизованного зрителя
A переносит X под родителя R, у B он свёрнут/не загружен (B авторизован видеть R и X).
Сервер корректно шлёт B
moveTreeNode, но на клиентеplaceByPositionвозвращает ту жессылку (родитель не загружен) → редьюсер делает фолбэк
remove(prev, X)→ X исчезает.Комментарий обещает «вернётся при разворачивании R», но lazy-load гейтится
children.length===0и может не сработать.apps/client/src/features/websocket/tree-socket-reducers.ts:92-94—if (placed === prev) return treeModel.remove(prev, payload.id)apps/client/src/features/page/tree/model/tree-model.ts:233-234—placeByPositionвозвращаетprev, когда родитель не найденtree-socket-reducers.test.ts:64закрепляет удаление как штатное поведениеhasChildren, а не поlength===0.2. После реконнекта удалённый/перемещённый корень остаётся призраком
B держит сайдбар открытым → сон ноутбука → сокет отвалился. Пока офлайн, A удаляет
корневую страницу P. B просыпается → ресинк инвалидирует только
root-sidebar-pages,а
mergeRootTreesтолько дописывает новые корни и никогда не удаляет старый.P зависает фантомом, клик → 404. Пропущенные за разрыв WS-события не реплеятся.
apps/client/src/features/page/tree/utils/utils.ts:219-232—merged = [...prevRoots], append-onlyapps/client/src/features/user/connect-resync.ts:34-40— реконнект только инвалидирует два ключа8. Ресинк не обновляет уже загруженные дочерние узлы
Тот же реконнект: переносы/переименования внутри уже загруженных веток за время
разрыва теряются (ресинк трогает только корни,
mergeRootTreesсохраняет старыеподдеревья по ссылке).
connect-resync.ts:36-37,space-tree.tsx:94-113.9. Встречные одновременные перемещения создают цикл в дереве
A тащит X под Y, одновременно B тащит Y под X. Cycle-guard (чтение
getPageBreadCrumbs)и запись (
updatePage) не обёрнуты в транзакцию → оба проходят против устаревшегоснимка →
X.parent=YиY.parent=X: цикл без пути к корню, рекурсивные ancestor-CTEломаются, оба поддерева пропадают из сайдбара.
apps/server/src/core/page/services/page.service.ts:946,954— guard-чтение и запись без транзакцииpage.service.ts:469—movePageToSpaceиспользуетexecuteTx(доказывает, что транзакция возможна)db.transactionс serializable /SELECT ... FOR UPDATE.P2 — Случайная утечка приватного наружу (важно даже без атакующего: интернет открыт)
3. SEO-роут публичного шара утекает заголовок restricted-страницы
Страница A расшарена с
includeSubPages=true; её дочерняя B — permission-restricted(контент-API
/api/share/page-infoотдаёт по ней 404). НоGET /share/<key>/p/<slug-B>резолвит наследуемый шар через
getShareForPageнапрямую, минуя единый гейтresolveReadableSharePage, где единственном живёт проверкаhasRestrictedAncestor.Реальный
titleB уходит в<title>/og:title/twitter:titleанонимам и краулерам.apps/server/src/core/share/share-seo.controller.ts:64-92apps/server/src/core/share/share.service.ts:182-186— док-строка: «restricted потомки скрыты ТОЛЬКО здесь; getShareForPage их НЕ исключает»отдаёт старый title (не зовёт
isSharingAllowed); «голый»/share/:shareIdотдаётсяSPA без
noindexдаже приsearchIndexing=false.resolveReadableSharePageи проставлятьnoindexна все шар-роуты.P3 — ИИ молча портит ваш же контент
4. Ассистент правит не ту страницу
Клиент шлёт в
POST /api/ai-chat/streamтело, гдеopenPage.idуказывает на B, аopenPage.title= «Страница A» (рассинхрон навигации/двух вкладок).resolveCurrentPageResultвозвращает id и title из клиента без сверки с реальнойстраницей; модель рапортует «обновил Страницу A», а правит pageId B (CASL не мешает —
доступ есть).
apps/server/src/core/ai-chat/tools/current-page.util.ts:14-21,ai-chat.service.ts:79-81,249,262. Фикс: валидировать title по серверу либо непоказывать клиентский title как подтверждение.
6.
patchNode/deleteNodeбьют по всем узлам с одинаковымattrs.idDocmost дублирует id при copy/paste блока;
copyPageContentпишет исходный документдословно с теми же id. Один
patchNode(pageId,'dup',…)заменяет/удаляет все блокис этим id, слой инструмента не предупреждает при
count>1.packages/mcp/src/lib/node-ops.ts:184-213,222-250,client.ts:1606-1626.Фикс: при
replaced/deleted > 1возвращать ошибку/варнинг модели.10.
updatePageJson: split-brain title/телоЗаголовок пишется REST'ом до тела через collab; падение записи тела (таймаут persist)
оставляет новый title над старым телом, а инструмент кидает ошибку.
packages/mcp/src/client.ts:1312-1322(и:1062-1090для markdown).Фикс: писать title и тело атомарно либо откатывать title при провале тела.
P4 — Ваш счёт за LLM
5. Кап «100/час» считает запросы, а не стоимость
Один принятый запрос проходит
stopWhen: stepCountIs(5)(до 5 провайдер-вызовов), и накаждом шаге заново шлётся весь ~240 КБ транскрипт как ввод;
maxOutputTokensограничивает только вывод. Токенного/дневного бюджета нет — только 100 запросов/час
(а окно почасовое без дневного потолка → устойчивый суточный максимум ~×24).
Сам алгоритм лимитера корректен (атомарный Lua, fail-closed, без off-by-one).
apps/server/src/core/ai-chat/public-share-chat.service.ts:225,228,64-65.Фикс: добавить токенный и/или дневной бюджет, а не только счёт запросов.
P5 — Почему всё это будет тихо регрессировать
7. Интеграционный набор не запускается в CI
Единственная тест-команда CI —
pnpm -r test(только unit.spec.tsна моках).test:int(.int-spec.ts, реальные Postgres/Redis) нигде, кромеpackage.json, невызывается; в
test.ymlнетservices:БД. Тесты кост-капа, FK-каскадов, jsonb-merge иреального apply ИИ никогда не идут в CI — регрессии в P1–P4 остаются зелёными.
Контроллер-тест мокает
tryConsumeWorkspaceQuota; ИИ-инструменты записи тестируютсяпротив стаба-рекордера (форма вызова, не результат применения).
.github/workflows/test.yml,apps/server/package.json:26-28,apps/server/test/jest-integration.json:4services: postgres/redisи шагpnpm --filter server test:intвtest.yml.Проверено и безопасно (не баг)
Manage Settings), не-админ не может сохранить дословно-инжектируемый сниппет.allow-scriptsбезallow-same-origin,no-referrer, opaque origin; кодек высоты гвардит NaN/Infinity.dist=300в проде» —apps/server/distв.gitignore, Docker делаетRUN pnpm build⇒ прод получает 100 (стейл-300 только в локальном непересобранном dist разработчика).Сгенерировано red-team-аудитом (Claude Code). Полные доказательства и спот-чеки — в
red-team-report.md.