git-sync red-team #7/#8: ядро Docmost — move-TOCTOU цикл A↔B + рекурсивные CTE без cycle-guard #207
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?
Найдено ред-тимом git-sync (PR #119), но это пре-existing баги ЯДРА Docmost (shared
PageService/page.repo), а не git-sync — поэтому выносится в отдельный issue для фикса в мейнлайне (по решению владельца). git-sync лишь делает их более достижимыми (конкурентные правки структуры дерева через два писателя).#7 — TOCTOU в
PageService.movePage: два конкурентных move создают цикл A↔Bpage.service.ts(~923-945): проверка циклов (getPageBreadCrumbs) и самupdatePage— два НЕтранзакционных, неблокируемых стейтмента. Если move-1 («A под B») и move-2 («B под A») оба читают ДО-записанный ацикличный снапшот до того, как любой из них закоммитит — оба проходят guard и персистятA.parentPageId=B.idИB.parentPageId=A.id→ цикл родитель/потомок в дереве.Репро (jest, реальная БД): засидить A,B как корни в одном спейсе; параллельно (Promise.all + барьер на
getPageBreadCrumbs) вызватьmovePage(A под B)иmovePage(B под A); обойтиparentPageIdот A и от B — оказывается цикл (не доходит до корня, повтор id).Fix: выполнять cycle-check и UPDATE в ОДНОЙ транзакции с блокировкой (SELECT ... FOR UPDATE по затрагиваемым строкам / advisory-lock на спейс), либо проверять цикл в самом UPDATE.
#8 — рекурсивные CTE (ancestors/descendants) без CYCLE/depth-guard
getPageBreadCrumbs(page.service.ts~986-1016) и descendant-CTE вremovePage(~1135-1147) используютwithRecursive+unionAllБЕЗCYCLE-клаузы и без ограничения глубины. Если в БД уже есть цикл (см. #7), эти запросы зависают/таймаутят — и сам move-guard зовёт тот же CTE, т.е. цикл «самозащищается» (move-guard перестаёт работать). Движок git-sync (layout.ts~91-105) имеет visited-set — асимметрия, доказывающая, что в ядре защиты нет.Репро (jest, реальный PG): INSERT/UPDATE напрямую так, чтобы
A.parentPageId=B.idиB.parentPageId=A.id(обойти guard); поставить короткийstatement_timeout; вызватьgetPageBreadCrumbs(A)→ таймаут/переполнение вместо ограниченного списка.Fix: добавить
CYCLE-клаузу (Postgres 14+) или depth-counter с порогом во все рекурсивные CTE обхода дерева (ancestors + descendants).Источник: ред-тим PR #119 (findings #7/#8), оба воспроизведены чтением кода. Тесты-репродукции пока не закоммичены (требуют реального PG в CI; см. также
test:int-инфраструктуру).