[security][audit] Red-team аудит (85db20f9..HEAD): потеря страниц, утечка title шара, ИИ правит не ту страницу, кап стоимости, CI #159

Closed
opened 2026-06-24 12:27:57 +03:00 by claude_code · 0 comments
Collaborator

Red-team аудит диапазона 85db20f9..HEAD (30 коммитов)

Модель угроз: самохостный блог/вики на ~5 доверенных людей, без серьёзного
внешнего атакующего; никто не будет целенаправленно ронять Redis, выжирать память
или жечь токены. Поэтому DoS / исчерпание ресурсов / спуфинг XFF / крафтовые
пэйлоады намеренно исключены из приоритета. В фокусе — потеря/порча данных при
обычной работе, случайная утечка приватного наружу, ИИ, молча портящий контент, и
собственный счёт за LLM.

Аудит: 6 read-only измерений параллельно; 7 ключевых утверждений перепроверены против
исходников (5 подтверждено, 1 — по механизму, 1 опровергнуто). Полный отчёт —
локально в red-team-report.md (на ветке develop, ещё не закоммичен).

Приоритеты (severity × вероятность × простота проверки)

# Находка Тип severity spot-check
1 Страница молча исчезает при перемещении в незагруженный/свёрнутый родитель data-loss high подтверждено
2 После реконнекта удалённый/перемещённый корень остаётся призраком (404 по клику) data-loss high подтверждено
3 SEO-роут шара утекает заголовок restricted-страницы анониму и в Google exposure high подтверждено
4 Ассистент правит не ту страницу: currentPage (id+title) не валидируется ai-corruption high подтверждено
5 Кап «100/час» считает запросы, а не токены (5 шагов + неогр. ~240 КБ ввода) cost high
6 patchNode/deleteNode бьют по всем узлам с одинаковым attrs.id ai-corruption high
7 Интеграционный набор (кост-кап, FK-каскады, реальный apply ИИ) не идёт в CI test-gap high подтверждено
8 Ресинк после реконнекта не обновляет загруженные дочерние узлы data-loss high
9 Встречные одновременные перемещения создают цикл в дереве data-corruption high механизм
10 updatePageJson: split-brain title/тело при падении записи тела ai-corruption medium

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-94if (placed === prev) return treeModel.remove(prev, payload.id)
  • apps/client/src/features/page/tree/model/tree-model.ts:233-234placeByPosition возвращает 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-232merged = [...prevRoots], append-only
  • apps/client/src/features/user/connect-resync.ts:34-40 — реконнект только инвалидирует два ключа
  • Фикс: на реконнекте делать replace-набора корней, а не merge-append.

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:469movePageToSpace использует executeTx (доказывает, что транзакция возможна)
  • Фикс: обернуть guard→update в 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.
Реальный title B уходит в <title>/og:title/twitter:title анонимам и краулерам.

  • apps/server/src/core/share/share-seo.controller.ts:64-92
  • apps/server/src/core/share/share.service.ts:182-186 — док-строка: «restricted потомки скрыты ТОЛЬКО здесь; getShareForPage их НЕ исключает»
  • Сопутствующее: после выключения шаринга на уровне воркспейса/спейса SEO-путь всё равно
    отдаёт старый title (не зовёт isSharingAllowed); «голый» /share/:shareId отдаётся
    SPA без noindex даже при searchIndexing=false.
  • Фикс: SEO-путь обязан идти через 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.id
Docmost дублирует 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:4
  • Фикс: добавить services: postgres/redis и шаг pnpm --filter server test:int в test.yml.

Проверено и безопасно (не баг)

  • trackerHead — путь записи реально admin-gated (Manage Settings), не-админ не может сохранить дословно-инжектируемый сниппет.
  • html-embed sandbox — корректен: allow-scripts без allow-same-origin, no-referrer, opaque origin; кодек высоты гвардит NaN/Infinity.
  • Алгоритм лимитера — атомарный, fail-closed, без off-by-one, member уникален.
  • ИИ не превышает прав пользователя — авторизация целиком на CASL под JWT пользователя.
  • Опровергнуто: «устаревший dist=300 в проде» — apps/server/dist в .gitignore, Docker делает RUN pnpm build ⇒ прод получает 100 (стейл-300 только в локальном непересобранном dist разработчика).

Сгенерировано red-team-аудитом (Claude Code). Полные доказательства и спот-чеки — в red-team-report.md.

## Red-team аудит диапазона `85db20f9..HEAD` (30 коммитов) > **Модель угроз:** самохостный блог/вики на ~5 **доверенных** людей, без серьёзного > внешнего атакующего; никто не будет целенаправленно ронять Redis, выжирать память > или жечь токены. Поэтому DoS / исчерпание ресурсов / спуфинг XFF / крафтовые > пэйлоады **намеренно исключены** из приоритета. В фокусе — потеря/порча данных при > обычной работе, случайная утечка приватного наружу, ИИ, молча портящий контент, и > собственный счёт за LLM. Аудит: 6 read-only измерений параллельно; 7 ключевых утверждений перепроверены против исходников (5 подтверждено, 1 — по механизму, **1 опровергнуто**). Полный отчёт — локально в `red-team-report.md` (на ветке `develop`, ещё не закоммичен). ### Приоритеты (severity × вероятность × простота проверки) | # | Находка | Тип | severity | spot-check | |---|---------|-----|----------|-----------| | 1 | Страница молча исчезает при перемещении в незагруженный/свёрнутый родитель | data-loss | high | ✅ подтверждено | | 2 | После реконнекта удалённый/перемещённый корень остаётся призраком (404 по клику) | data-loss | high | ✅ подтверждено | | 3 | SEO-роут шара утекает заголовок restricted-страницы анониму и в Google | exposure | high | ✅ подтверждено | | 4 | Ассистент правит **не ту страницу**: `currentPage` (id+title) не валидируется | ai-corruption | high | ✅ подтверждено | | 5 | Кап «100/час» считает запросы, а не токены (5 шагов + неогр. ~240 КБ ввода) | cost | high | — | | 6 | `patchNode`/`deleteNode` бьют по **всем** узлам с одинаковым `attrs.id` | ai-corruption | high | — | | 7 | Интеграционный набор (кост-кап, FK-каскады, реальный apply ИИ) **не идёт в CI** | test-gap | high | ✅ подтверждено | | 8 | Ресинк после реконнекта не обновляет загруженные дочерние узлы | data-loss | high | — | | 9 | Встречные одновременные перемещения создают **цикл** в дереве | data-corruption | high | ✅ механизм | | 10 | `updatePageJson`: split-brain title/тело при падении записи тела | ai-corruption | medium | — | --- ### 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-only - `apps/client/src/features/user/connect-resync.ts:34-40` — реконнект только инвалидирует два ключа - _Фикс:_ на реконнекте делать replace-набора корней, а не merge-append. **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` (доказывает, что транзакция возможна) - _Фикс:_ обернуть guard→update в `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`. Реальный `title` B уходит в `<title>`/`og:title`/`twitter:title` анонимам и краулерам. - `apps/server/src/core/share/share-seo.controller.ts:64-92` - `apps/server/src/core/share/share.service.ts:182-186` — док-строка: «restricted потомки скрыты ТОЛЬКО здесь; getShareForPage их НЕ исключает» - Сопутствующее: после выключения шаринга на уровне воркспейса/спейса SEO-путь всё равно отдаёт старый title (не зовёт `isSharingAllowed`); «голый» `/share/:shareId` отдаётся SPA без `noindex` даже при `searchIndexing=false`. - _Фикс:_ SEO-путь обязан идти через `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.id`** Docmost дублирует 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:4` - _Фикс:_ добавить `services: postgres/redis` и шаг `pnpm --filter server test:int` в `test.yml`. --- ### Проверено и безопасно (не баг) - **trackerHead** — путь записи реально admin-gated (`Manage Settings`), не-админ не может сохранить дословно-инжектируемый сниппет. - **html-embed sandbox** — корректен: `allow-scripts` **без** `allow-same-origin`, `no-referrer`, opaque origin; кодек высоты гвардит NaN/Infinity. - **Алгоритм лимитера** — атомарный, fail-closed, без off-by-one, member уникален. - **ИИ не превышает прав пользователя** — авторизация целиком на CASL под JWT пользователя. - **Опровергнуто:** «устаревший `dist=300` в проде» — `apps/server/dist` в `.gitignore`, Docker делает `RUN pnpm build` ⇒ прод получает 100 (стейл-300 только в локальном непересобранном dist разработчика). --- <sub>Сгенерировано red-team-аудитом (Claude Code). Полные доказательства и спот-чеки — в `red-team-report.md`.</sub>
claude_code added the bugtestsecurity labels 2026-06-24 12:27:57 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#159