Перейти к содержимому
Pre-MVP. Кодовая база 1.0 не является выпущенным продуктом — см. границы продукта 1.0.

Безопасность плагинов

Что такое токен инсталляции Dee Wan, как он хранится и ротируется, как полномочия человека доходят до команды плагина, что на самом деле ограничивает грант и что может и чего не может сделать тот, кто захватит плагин.

Каждый пример здесь выполняется против настоящих маршрутов тестом backend/test/plugin-docs.test.ts. Идентификаторы и секреты синтетические.

См. также Protocol v1 — поверхность маршрут за маршрутом, и Написание плагина — процесс инсталляции.

browser ── user session ──► Dee Wan admin Worker
│ control database: installations, token hashes, grants, launch codes
│ tenant database: content, versions, workflow, audit
│ HTTPS + installation bearer token
plugin service ◄──────────────┘
│ its own database, its own clock, its own sessions
└── HTTPS + installation bearer token ──► /api/plugin/v1 ──► the same lifecycle service a person uses

Эту границу удерживают три свойства:

  1. API плагинов — это не административный API. Он находится под /api/plugin/v1, имеет собственный middleware и разделяет только сервис жизненного цикла — второй реализации перехода, которая могла бы разойтись с той, которой пользуется человек, не существует.
  2. Никакого кода плагина, никакой разметки плагина. Ядро не загружает модулей, не исполняет скриптов, не рендерит удалённый HTML или CSS и не встраивает iframe. Единственное влияние плагина на админ-интерфейс — ограниченное действие { id, label } и ссылка.
  3. Никакого доступа плагина к базе данных. Плагин не получает ни клиента Prisma, ни привязки D1, ни учётных данных R2, ни доступа к аккаунту Cloudflare. Всё, что он может прочитать или изменить, проходит через пять маршрутов.
Форма dwp_ плюс 43 URL-безопасных символа — 256 бит случайности из crypto.getRandomValues
Передаётся плагину Один раз, при передаче активации, по HTTPS на собственный origin манифеста
Хранится ядром Только хеш SHA-256. Нет ни одного пути в коде, который мог бы снова вывести токен
Отправляется плагином Authorization: Bearer <token>, в каждом запросе
Никогда не попадает в URL, параметр запроса, тело ошибки, строку лога, запись аудита или фикстуру
Область действия Одна инсталляция, то есть один плагин на одном сайте

Префикс нужен для того, чтобы утёкшую строку можно было распознать как учётные данные плагина Dee Wan; учётными данными её делает энтропия. Некорректное значение bearer отклоняется по форме ещё до чтения из базы данных, так что попытка подбора стоит атакующему поиска, до которого он никогда не доходит.

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

  1. Администратор получает от плагина новый код активации.
  2. Ядро выпускает новый токен и отправляет его вместе с этим кодом на закреплённый activation_url из сохранённого манифеста. Не на URL, переданный в момент ротации.
  3. Ядро заменяет хеш только при ответе 2xx. Если плагин отказывает или недоступен, старый токен продолжает работать — неудачная ротация никогда не должна отрезать работающую инсталляцию.
  4. Предыдущий хеш остаётся действительным не более 10 минут, чтобы уже выполняющийся запрос не был прерван посреди ротации.
  5. Первый запрос с НОВЫМ токеном немедленно завершает это перекрытие. Плагин, который быстро переходит на новый токен, сужает собственное окно уязвимости.

Код активации никогда не хранится и никогда не попадает в логи — ни на одной из сторон.

disabled и revoked оба останавливают API плагинов на входе — до доступа к арендатору (tenant), до чтения тела. revoked терминален: хеши удаляются, и этот токен уже никогда не может стать действительным. Удаление сначала выполняет отзыв, и Dee Wan не делает никаких заявлений о данных, которых не видит.

Интерфейс управления плагина — отдельная страница, а cookie сессии Dee Wan никогда не покидают Dee Wan. Связь между ними — код запуска:

  • случайный, одноразовый, хранится в хешированном виде и действителен 60 секунд;
  • привязан к инсталляции, сайту, пользователю, действию и — для действия над контентом — к одному экземпляру контента;
  • обменивается БЭКЕНДОМ плагина, с его собственным bearer-токеном, на запись делегирования;
  • код, выпущенный для одной инсталляции, не может быть обменян другой;
  • ответ запуска содержит Referrer-Policy: no-referrer, а удалённая страница не должна загружать никаких сторонних ресурсов до обмена.

Перед выпуском кода ядро проверяет: у запрашивающего есть сессия и доступ к сайту, manage требует site:plugins, а действие над контентом требует права чтения этого конкретного элемента.

Делегирование — это не учётные данные. Оно не несёт никаких возможностей. Оно несёт идентичность — и именно это делает возможным повторное чтение полномочий:

Каждая команда плагина указывает delegation_id, и ядро заново читает ТЕКУЩИЕ полномочия этого человека из управляющей базы данных, прежде чем что-либо записать. Полномочия, зафиксированные при создании расписания, никогда не переносятся вперёд.

Поэтому все следующие случаи отказывают при выполнении, даже если делегирование было действительным в момент создания: человек лишился роли на сайте, его аккаунт деактивирован, его роль больше не включает право доступа на публикацию или делегирование принадлежит другой инсталляции. Чтение с delegation_id точно так же сужает предлагаемые переходы и отказывает, а не молча возвращает более короткий список.

Человек, который попросил, больше не обладает нужными полномочиями

Окно терминала
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0009:r1" \
--data '{"kind":"publish","expected_current_version_id":"cdocsversion00000000000001","expected_workflow_revision":0,"delegation_id":"pld_0000000000000000000000000000d1ee"}'
{
"outcome": {
"code": "requester_unauthorized",
"message": "The person who requested this no longer holds the authority it needs.",
"retry": false
}
}

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

Единственное намеренное исключение — повтор. Idempotency-Key, который уже был применён, возвращает первый результат, даже если запрашивающий с тех пор лишился полномочий: работа уже сделана, и повторный ответ на неё не является новой выдачей разрешения.

Сайт берётся из токена и ни из чего, что отправляет вызывающая сторона. В запросе плагина нет X-Site-Id, нет сайта в теле и нигде нет имени базы данных: и то и другое отклоняется сразу, а не игнорируется, чтобы ошибка в плагине не могла превратиться в чтение чужого арендатора.

Указание базы данных

Окно терминала
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "X-D1-Binding: SOME_OTHER_TENANT"
{
"outcome": {
"code": "invalid_request",
"message": "A plugin request cannot name a database.",
"retry": false
}
}

Доступ к моделям — это пересечение возможности и сохранённого списка id моделей контента. Выход за пределы области получает ответ content_not_found — тот же, что и «такого контента нет», поэтому плагин не может составить карту структуры сайта перебором.

Id с другого сайта

Окно терминала
curl "https://cms.example/api/plugin/v1/content/cotherinstance0000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "content_not_found",
"message": "No such content in this installation’s scope.",
"retry": false
}
}

Суженный грант применяется к самому следующему запросу:

После того как администратор убрал модель из гранта

Окно терминала
curl "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "content_not_found",
"message": "No such content in this installation’s scope.",
"retry": false
}
}

Гранты хранят id моделей, поэтому переименование модели сохраняет грант, а удаление модели делает эту часть гранта недействующей. Административный интерфейс показывает текущие slug рядом с id.

Cookie пользователя — это не аутентификация плагина. Маршруты плагинов читают Authorization и больше ничего, поэтому украденную браузерную сессию нельзя воспроизвести против API плагинов:

Cookie сессии на маршруте плагина

Окно терминала
curl "https://cms.example/api/plugin/v1/context" \
-b "__Secure-better-auth.session_token=$SESSION"
{
"outcome": {
"code": "plugin_unauthorized",
"message": "The installation token is missing, unknown, disabled or revoked.",
"retry": false
}
}

Обратное тоже верно и покрыто тестом backend/test/plugin-installation.test.ts: токен плагина, предъявленный административному маршруту, отклоняется как неаутентифицированный. Этим маршрутам нужны сессия пользователя, заголовок сайта и site:plugins; у плагина нет ни одного из трёх.

Отключённая инсталляция, до любого доступа к арендатору

Окно терминала
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "plugin_unauthorized",
"message": "The installation token is missing, unknown, disabled or revoked.",
"retry": false
}
}

Один ответ покрывает отсутствующий, неизвестный, отключённый и отозванный токен. Ничто не сообщает вызывающей стороне, какой из случаев имеет место.

Запросы ограничиваются для каждой инсталляции — 120 за 60-секундное окно — с помощью того же механизма принуждения, что и в остальном продукте, и получают ответ в собственном конверте протокола с Retry-After. Это средства защиты от злоупотреблений. Ничто здесь не измеряется, не передаётся и не хранится как телеметрия.

Превышение лимита

Окно терминала
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "rate_limited",
"message": "Too many requests for this installation.",
"retry": true
}
}

Соблюдайте Retry-After и отклоняйте собственную запись, а не ждите, если задержка превышает максимум, выбранный вами заранее.

Ядро вызывает плагин ровно дважды: при получении манифеста и при передаче активации или ротации. Оба вызова ограничены и закреплены.

  • Только HTTPS. Учётные данные в URL, фрагменты, loopback, link-local, приватные и зарезервированные адреса назначения отклоняются, как и IP-литералы и имена без точки.
  • Редиректы отклоняются, а не выполняются. 3xx — это remote_redirect_refused.
  • Тело ответа ограничено 32 KB, а вызов — 5 секундами.
  • Все URL — манифест, базовый, активации, управления — должны иметь один origin, чтобы активацию нельзя было направить туда, куда не указывал проверенный манифест.
  • Удалённый ответ разбирается как недоверенный ввод по схеме манифеста. Неизвестный ключ — это отказ, а не то, что можно проигнорировать.
  • Workers не могут разрешить DNS до fetch, поэтому проверяется то, что указано в самом URL. Публичное имя, которое разрешается в приватный адрес, находится вне того, что видит проверка, и так и указано в url-policy.ts.
  • Локальный HTTP возможен только при ENVIRONMENT=development и если точный origin указан в PLUGIN_DEV_ORIGINS. Никаких подстановочных знаков, никаких упрощений для приватных диапазонов, никакого запасного варианта для продакшена.

Плагин — это действующее лицо, а не пользователь. Никакой фиктивный пользователь не создаётся, и id инсталляции не записывается во внешний ключ пользователя.

Переход, выполненный плагином, фиксирует id инсталляции, принятые id, имя и версию плагина, версию протокола, дайджест ключа идемпотентности — но никогда не сам ключ, — итоговое состояние и указатель публикации, а также email человека, чьё делегирование его авторизовало. Идентичность инсталляции хранится в управляющей базе данных, а аудит workflow — в базе данных арендатора, без межбазового внешнего ключа: событие арендатора хранит неизменяемый снимок. Поэтому же удаление не может стереть атрибуцию.

Административные действия — установка, изменение гранта, принятие манифеста, ротация, отключение, включение, удаление — записываются в административный журнал аудита от имени действующего пользователя и отказывают (503 audit_unavailable), а не выполняют изменение без аудита.

Если плагин полностью скомпрометирован, у атакующего есть один токен инсталляции для одного сайта, и он может:

  • читать узкую сводку контента для моделей из гранта — id, метку, slug, id версий, состояние workflow и граничные переходы;
  • публиковать или снимать с публикации контент в этих моделях, но только указывая всё ещё действительный delegation_id, чей человек всё ещё обладает полномочиями, закрепляя точную версию и совпадая с текущей ревизией workflow;
  • открывать маршрут обмена с кодами, которые у него уже есть.

Он не может:

  • добраться до другого сайта, другой модели или делегирований другой инсталляции;
  • читать или записывать значения полей, медиа, пользователей, роли, сессии, настройки, модели или аудит;
  • выполнять код, рендерить разметку или добраться до базы данных, R2 или любых учётных данных Cloudflare;
  • опубликовать более новый черновик, чем закреплённый, пройти изменившийся workflow, обойти зависимый контент или воспользоваться каким-либо маршрутом переопределения — в protocol v1 их нет;
  • действовать от имени человека, лишившегося полномочий, или подделать его: delegation_id — это поиск, а не утверждение;
  • превратить повтор мутации во второй эффект или скрыть его из журнала аудита.

Если утёк код запуска — через referrer, лог, общий URL, — он одноразовый, ему не более 60 секунд, хранится хешированным и бесполезен без токена инсталляции, который его обменивает.

Если скомпрометирован администратор, он может установить плагин и выдать ему то, что есть у него самого. Это реальный риск данной архитектуры, и именно поэтому site:plugins — отдельное право доступа, изначально выдаваемое только роли администратора и никогда не подразумеваемое правом на публикацию, и поэтому каждый шаг установки проходит аудит, а каждый грант выбирается вручную из явного запроса манифеста.

От чего Dee Wan не может защитить — это собственная эксплуатация плагина: его доступность, его часы, его политика повторов, что он хранит в своей базе данных и удаляет ли он ваши данные, когда вы об этом просите. На эти вопросы должна отвечать собственная документация плагина, и администратор должен прочитать её, прежде чем что-либо выдавать. Как это выглядит, см. в Плагин планирования.