Skip to content

Дверей стало две, замок остался один

Коммитс: право отзывалось в одном месте, а предъявлялось в двух. Разбор про себя — с кодом, написанным моделью, которая объяснила свою безопасность на пять абзацев и не вышла за пределы своего файла.

v2 0 stars 0 forks 0 watchers 1 branch 0 runs Public
Добавлен пуант — он был в файле разбора, но потерялся при сборке спискаv2

Equiply, 1 сентября 2026 года. Разбор про себя — по правилу «мы попадаем в разборы наравне».

Оговорка сразу: репозиторий приватный, поэтому ссылку на коммит читатель не откроет. Хеши и тексты сообщений приводятся дословно, но проверить их снаружи нельзя. Это слабое место разбора, и делать вид, что его нет, было бы против самого метода.

8 июня 2026 — рождается замок.

Первый коммит проекта, 9434579, называется длинно и по-хозяйски: AEP — портал приводчиков (оборудование, склад, руководства, наряды, ИИ-чат).

Внутри — функция, которая на каждом запросе решает, кто перед нами:

ts
1if (row.user.deletedAt || row.user.status === 'blocked') return null

Заблокировали человека — он перестаёт входить со следующего же запроса. Одна строка, и она работает два с половиной месяца без единой правки.

23 августа, 23:32 — строится вторая дверь.

Приезжает коммит c1ab6db: feat(mcp): персональные токены доступа в профиле пользователя.

Раньше машины ходили в данные портала одним общим ключом из окружения. Теперь каждый выписывает себе личный.

Это не короткое сообщение на строку. Оно на пять абзацев, и почти все — про безопасность:

Токен — aep_ плюс 32 случайных байта; в базе только SHA-256 от него, поэтому утечка таблицы доступа не даёт, а пользователю он показывается один раз. Хеш без соли и не bcrypt намеренно: это не пароль, а 256 бит энтропии — перебирать нечего, а быстрый хеш нужен для поиска индексом. Отзыв мягкий: строка остаётся, чтобы не терять историю «кто ходил».

Продумано хранение. Продуман отзыв. Продуман журнал. Продумано даже то, чего намеренно не делают и почему.

А проверка на предъявлении получилась такая:

ts
1if (!row || row.revokedAt) return null

Токен не отозван — проходи.

Про то, жив ли ещё человек, за которым этот токен закреплён, не сказано ни слова. Ни в коде, ни в сообщении.

1 сентября — первый проход ревью.

Одна линза за проход, свой вопрос, находка доказывается прогоном, а не чтением. Линза первая — доступ и права. Обход начинается с машинных поверхностей: там цена ошибки выше.

Проба дёргает настоящий verifyToken, а не свою копию его логики:

code
1активный → ДОСТУП ЕСТЬ
2заблокирован → ДОСТУП ЕСТЬ
3мягко удалён → ДОСТУП ЕСТЬ

Администратор нажимает «Заблокировать». Человек перестаёт входить на сайт в ту же секунду — замок с 8 июня работает.

Его ИИ-клиент в это время продолжает выкачивать реестр оборудования, все 2624 позиции, склад, движения ТМЦ и ежедневник. Ровно так же, как вчера. И будет завтра.

Отозвать нечего: блокировка токены не трогает, удаление тоже. А посмотреть, какие токены выписаны у сотрудника, администратор не может — чужие токены в интерфейсе не показываются.

Между постройкой второй двери и находкой — девять дней.

Дверь, которую никто не открывал.

Дальше та часть, без которой разбор был бы враньём.

sql
1select count(*) from mcp_tokens;
2-- 0

Ни одного токена не выписано ни разу. Дыра существовала девять дней в коде и ноль секунд в жизни: чтобы в неё пройти, нужно было сначала завести ключ, а ключей нет.

Это не делает находку менее настоящей — первый же выписанный токен пережил бы увольнение своего хозяина. Но разницу полагается называть вслух: нашли до того, как понадобилось, а не после того, как утекло.

Починка.

Напрашивалось отзывать токены при блокировке. Не стали: массовый отзыв работает только там, где о нём вспомнили, обязан быть повторён в каждом новом пути блокировки — и не закрывает токен, выписанный уже после неё.

Вместо этого условие переехало туда, где ему и место, — в саму проверку предъявления:

ts
1if (row.userDeletedAt || row.userStatus === 'blocked') return null

Та же строка, что на 8 июня. Теперь она есть у обеих дверей.

Почему это разбор про машину.

Оба коммита — и тот, что построил дверь, и тот, что навесил замок, — подписаны Co-Authored-By: Claude Opus 5.

Обычно в таких разборах самое дорогое — восстановить ход мысли: люди объясняют себя скупо. Здесь наоборот. Модель объясняет себя избыточно: пять абзацев рассуждений там, где человек написал бы «добавил токены». Ход мысли не надо восстанавливать — он выписан целиком.

И он был не поверхностным. Ошибка не в том, что не подумали. Думали много и вслух.

Ошибка в том, что думали про свою дверь. Проверка, жившая в соседнем модуле с 8 июня, в поле зрения не попала — не потому что её сочли ненужной, а потому что её не увидели. Ревью ветки прочитало бы те же пять абзацев и согласилось бы с каждым.

И последнее.

Тот самый коммит от 23 августа, третьим абзацем, объясняет, зачем вообще заводились личные токены:

…а уход сотрудника не требует менять ключ всем сразу.

Уход сотрудника был единственным сценарием, ради которого всё это писалось. Он же оказался единственным, который не работал.

Что это значит для нас

1
Пересчитать двери к своим данным

Выписать ВСЕ пути, которыми снаружи попадают к одним и тем же данным: сессия, личный токен, служебный ключ, публичная ссылка, вебхук, очередь.

Why: Проверка, существующая в одном экземпляре из двух, — не проверка. Если право отзывается в одном месте, а предъявляется в двух, оно отозвано наполовину.
$grep -rn "getCurrentUser\|verifyToken\|Bearer\|shareToken" src/ | cut -d: -f1 | sort -u
Check
  • Список путей выписан целиком, а не по памяти
  • Для каждого пути назван замок, который на нём стоит
  • Найден хотя бы один путь, о котором вспомнили только при составлении списка
2
Заменить действие при блокировке на проверку при предъявлении

Не «при блокировке отозвать всё выданное», а «на входе спросить, жив ли владелец».

Why: Действие нужно помнить и повторять в каждом новом пути блокировки, который когда-нибудь появится, и оно не закрывает ключ, выданный уже после. Проверка срабатывает всегда, включая случаи, о которых никто не подумал заранее.
Check
  • Состояние владельца проверяется в момент предъявления права
  • Проверка живёт рядом с проверкой отзыва, а не в отдельном месте
  • Новый путь выдачи ключа не требует помнить про отзыв
3
Не принимать длину объяснения за полноту покрытия

Перечитать последний большой коммит про безопасность и спросить: сколько из этого рассуждения выходит за пределы изменённого файла?

Why: Пять абзацев про энтропию, соль и журнал создают ощущение, что безопасность рассмотрена. Рассмотрена была одна её половина. Объём рассуждения говорит о старательности автора, а не о полноте охвата.
Check
  • Названо, какие соседние модули коммит должен был учесть
  • Проверено, упомянут ли хоть один из них
4
Доказать прогоном, а не чтением

Написать пробу, которая дёргает НАСТОЯЩУЮ функцию проверки прав и прогоняет её по состояниям владельца: активен, заблокирован, удалён.

Why: «Здесь стоит проверка» доказательством не является: проверка бывает второй копией, которая отстала. Проба, повторяющая логику своими словами, проверяет саму себя.
Check
  • Проба вызывает настоящий код, а не копию его логики
  • До правки проба проваливается, после — проходит
  • У пробы есть предохранитель: на боевой базе она отказывается работать

Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода

updated Sep 1, 2026

How the Jest project lives and accepts external contributions, and who has been involved in its discussions - by actions, dates, and links

updated Sep 2, 2026

Журнал движений и кэш остатка расходились в ERPNext десять лет: задним числом, вперёд, через гонку, через вторую дверь. Последний коммит удаляет мёртвую функцию — только за то, что она второй путь записи.

updated Sep 2, 2026

Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам

updated Sep 2, 2026

Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам

updated Sep 2, 2026

Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам

updated Sep 2, 2026