Почему custom controller в Strapi 5 возвращает private fields и как правильно очистить ответ
Вы поставили полю private: true, проверили штатный REST — всё нормально. Потом написали custom controller, вернули результат Document Service, и закрытое поле снова оказалось в JSON. Причина — пропущенная очистка на выходе. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», покажу, где проходит эта граница, какой контроллер я выбираю для публичной выдачи и что проверяю перед открытием маршрута клиентам.
1. Document Service возвращает данные для серверного кода
Первое, что я предлагаю проверить, — последнюю операцию перед отправкой ответа. Если там стоит ctx.send(records), где records получены через strapi.documents(), вы самостоятельно связали внутренний слой доступа к данным с внешним API. Document Service не применяет к результату правила видимости Content API. Он может вернуть private fields, поля типа password и загруженные relations, недоступные текущему посетителю. Это прямо обозначено в [документации Document Service](https://docs.strapi.io/cms/api/document-service).
У штатного REST между получением записей и HTTP-ответом работает очистка. Когда вы полностью заменяете действие контроллера, эту последовательность приходится восстановить. Само использование createCoreController не означает, что любой возвращаемый вашим методом объект автоматически проверят. Фабрика предоставляет методы, которые нужно вызвать. Если задача решается через super.find(ctx), я сохраняю штатное действие: так меньше собственного кода, отвечающего за безопасность.
Отдельная ловушка — transformResponse(). Название выглядит внушительно, но этот метод занимается представлением ответа, включая оболочку data и meta. Удаление секретных атрибутов в его обязанности не входит. ctx.send() и присваивание ctx.body тоже не превращают внутренний объект в безопасный публичный документ. Поэтому на ревью я ищу явную последовательность: получение данных, sanitization, формирование ответа. Поведение форматтера можно проверить в [исходнике Strapi 5.41.1](https://raw.githubusercontent.com/strapi/strapi/v5.41.1/packages/core/core/src/core-api/controller/transform.ts).
2. Что именно защищают private и password
Для закрытого бизнес-поля я явно задаю private в схеме. Имя internalNote само по себе ничего не гарантирует: для Strapi это обычный атрибут. Ниже фрагмент src/api/article/content-types/article/schema.json; остальные обязательные части схемы, включая info, остаются в сгенерированном файле. Настройка описана в [документации моделей](https://docs.strapi.io/cms/backend-customization/models). ```json { "options": { "draftAndPublish": true }, "attributes": { "title": { "type": "string" }, "internalNote": { "type": "text", "private": true }, "accessCode": { "type": "password" }, "reviewer": { "type": "relation", "relation": "manyToOne", "target": "api::profile.profile" } } } ```
У связанного profile задаю displayName как string, email как email с private: true и password как password. Очистка учитывает схему вложенной модели: разрешённый профиль может остаться в ответе, а его закрытые атрибуты должны исчезнуть. Если закрыта сама relation, возможен другой результат — удаление всей связи. Я разделяю эти проверки, иначе легко убедиться только в исчезновении пароля и пропустить ненужную выдачу персональных данных.
Не нужно паниковать при каждом объекте пользователя в памяти серверного процесса. Внутренним операциям такие данные могут требоваться. Проблема возникает при пересечении границы доверия: HTTP-ответ, внешний webhook, диагностический лог с широким доступом. И хеш пароля нельзя считать безопасным содержимым публичного JSON. Он отличается от открытого пароля, но остаётся материалом учётной записи, который получателю выдавать незачем.
3. Практический разбор: публикации условного ООО «Вектор»
У проблемы есть конкретный публичный пример. В issue #26182 от 1 мая 2026 года разработчик описал custom endpoint на Strapi 5.41.1, Node.js 22.17.0 и MySQL. По его сообщению, внутри загруженных данных оказались закрытые поля admin::user, включая хеши паролей и поля токенов сброса. Ответ отправлялся через ctx.send(). Это свидетельство автора обращения; детали его окружения я независимо не воспроизводил. [Исходное сообщение](https://github.com/strapi/strapi/issues/26182).
Для разбора я использую учебную конфигурацию условного ООО «Вектор»: две опубликованные статьи, один неопубликованный документ и один связанный профиль. У article включён Draft & Publish, у profile выключен. Роли Public разрешено article.find, но запрещено profile.find. Отдельной роли Integration разрешены оба действия. Название компании условное; это не раскрытие клиентского проекта. Полный стенд при подготовке статьи не запускался, поэтому далее я обозначаю ожидаемые результаты, проверенные по документации и исходному коду.
Ошибка в такой конфигурации помещается в несколько строк. Параметр published ограничивает версии документов, но никак не очищает их атрибуты. reviewer загружается целиком, а fields не ограничены, поэтому внутренние поля получают прямой путь в ответ: ```js const records = await strapi.documents('api::article.article').findMany({ status: 'published', populate: { reviewer: true }, }); return ctx.send({ data: records }); ``` Для демонстрации достаточно этих трёх статей: размер каталога не меняет саму ошибку.
Автор исходного обращения отметил, что ручная sanitization устраняла проблему выдачи закрытых данных. В учебной конфигурации критерий исправления конкретнее: посетитель получает две опубликованные статьи без внутренних полей и без содержимого reviewer. Клиент роли Integration может получить displayName профиля, но не email и password. Третий, неопубликованный документ отсутствует у обоих клиентов. Это четыре независимых свойства ответа, а не одна проверка наличия строки password.
4. Контроллер, который я выбираю для ограниченной выдачи
Для небольшой ленты я выбираю фиксированный контракт: максимум 20 статей, только опубликованные версии, заданные поля и связи. Параметры query этому маршруту не нужны, поэтому непустой query получает 400. Такой вариант проще сопровождать, чем универсальный конструктор запросов. Он сознательно ограничен: если клиенту нужна пагинация или фильтрация, её придётся отдельно спроектировать.
Создаю отдельный src/api/article/controllers/feed.js. Сгенерированный article.js сохраняю, чтобы штатные маршруты продолжали находить свои CRUD-действия. Маршрут помещаю в src/api/article/routes/01-feed.js. При стандартном REST-префиксе адрес будет /api/public-feed. Явный scope использует право чтения статей; его разрешаю нужным ролям через Users & Permissions. [Правила маршрутов](https://docs.strapi.io/cms/backend-customization/routes), [проверка scope в Strapi 5.41.1](https://raw.githubusercontent.com/strapi/strapi/v5.41.1/packages/plugins/users-permissions/server/strategies/users-permissions.js). ```js module.exports = { routes: [{ method: 'GET', path: '/public-feed', handler: 'api::article.feed.publicFeed', config: { auth: { scope: ['api::article.article.find'] }, }, }], }; ```
В контроллере сначала получаю документы, затем передаю их санитайзеру вместе со схемой и контекстом авторизации. И только из очищенного массива собираю небольшой DTO — объект внешнего контракта. reviewer предусмотрен для роли Integration; у Public после очистки его содержимого не будет. Здесь показан JavaScript-вариант без фабрики: ```js const UID = 'api::article.article'; module.exports = { async publicFeed(ctx) { const auth = ctx.state.auth; if (!auth) return ctx.unauthorized(); if (Object.keys(ctx.query).length > 0) { return ctx.badRequest('Query parameters are not supported'); } const raw = await strapi.documents(UID).findMany({ status: 'published', fields: ['title'], populate: { reviewer: { fields: ['displayName'] }, }, sort: ['title:asc'], limit: 20, }); const safe = await strapi.contentAPI.sanitize.output( raw, strapi.contentType(UID), { auth } ); const data = safe.map((item) => ({ documentId: item.documentId, title: item.title, reviewer: item.reviewer ? { displayName: item.reviewer.displayName } : null, })); return ctx.send({ data }); }, }; ```
Да, здесь одновременно используются fields, sanitization и явный DTO. Я оставляю все три элемента. fields сокращает объём извлекаемых данных, санитайзер применяет правила схемы и доступа, DTO закрепляет внешний контракт. При добавлении нового атрибута в CMS он не должен неожиданно появляться у потребителя API. При этом обёртка data собирается уже после очистки: санитайзеру передаются сами документы.
5. Контекст авторизации и схема должны соответствовать данным
В контроллере, созданном через createCoreController для article, тот же результат можно очистить через this.sanitizeOutput(raw, ctx). Фабрика сама подставляет схему article. Но если внутри этого действия вы отдельно получили profiles, использовать тот же helper уже неправильно: он связан с другой моделью. Для каждой самостоятельной коллекции указывайте её UID через strapi.contentType(uid). Это видно в [реализации методов фабрики](https://raw.githubusercontent.com/strapi/strapi/v5.41.1/packages/core/core/src/core-api/controller/index.ts).
Когда маршрут принимает пользовательские filters, sort, fields или populate, я добавляю проверку и очистку входного запроса. Внутри фабричного контроллера начало действия выглядит так: ```js await this.validateQuery(ctx); const query = await this.sanitizeQuery(ctx); ``` Дальше в Document Service передаются разрешённые параметры из query. Проверка сообщает об ошибке, очистка возвращает обработанный запрос, а sanitizeOutput обрабатывает результат. Подменять одно другим нельзя. В текущей таблице документации validate.query также не заявлен как полная проверка populate. [Sanitization и validation контроллеров](https://docs.strapi.io/cms/backend-customization/controllers#sanitization-and-validation-in-controllers).
Для публичного доступа я сохраняю штатную auth-стратегию и настраиваю Public. auth: false отключает её обработку; прямой вызов sanitize.output с отсутствующим auth не становится проверкой прав роли Public. В реализации 5.41.1 базовая очистка выполняется, а проход проверки доступа к relations подключается при наличии auth. Передавать нужно ctx.state.auth, а не ctx.state.user. У фабричного helper есть дополнительная подстановка пустого объекта, поэтому эти варианты нельзя механически считать одинаковыми. [Код санитайзера](https://raw.githubusercontent.com/strapi/strapi/v5.41.1/packages/core/utils/src/sanitize/index.ts).
Даже корректный auth не решает бизнес-правило «видеть документы только своей организации». Проверка закрытой relation опирается на доступ к действию find целевой модели. Принадлежность конкретной записи предприятию нужно обеспечивать политиками и серверными условиями выборки. Если вы делаете B2B-кабинет на одной базе для нескольких заказчиков, я проверяю эту часть отдельно от удаления private fields. [Проверка доступа к relations](https://raw.githubusercontent.com/strapi/strapi/v5.41.1/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts).
6. Где правильный вызов всё равно превращается в утечку
Самая обидная ошибка — вызвать санитайзер и проигнорировать результат: await this.sanitizeOutput(raw, ctx), затем return raw. Обход создаёт очищенную копию. Поэтому переменная safe в примере принципиальна. Следующий похожий промах — после очистки добавить в DTO сведения из raw. Например, переименовать raw.reviewer.email в contact. Новое имя не делает значение публичным. Порядок должен оставаться односторонним: raw → safe → DTO. [Обход сущностей в Strapi 5.41.1](https://raw.githubusercontent.com/strapi/strapi/v5.41.1/packages/core/utils/src/traverse-entity.ts).
Ещё я отдельно просматриваю атрибуты типа json и агрегаторы. Произвольное содержимое JSON не получает автоматически схему вложенного content-type. Если туда положили служебный токен, санитайзер не обязан догадаться о его назначении. Для агрегированного ответа сначала очищаю статьи схемой article, профили схемой profile, затем объединяю результаты. Попытка очистить готовый объект { articles, profiles } одной схемой слишком легко оставляет данные вне ожидаемого обхода.
Наконец, статус публикации проверяю независимо. При включённом Draft & Publish Document Service по умолчанию работает с draft-версиями. Поэтому публичный обработчик явно задаёт status: 'published', и клиентские параметры не должны перезаписывать это значение. Чистый от паролей черновик всё ещё может раскрывать неопубликованный прайс или будущий анонс. Это отдельная ошибка выбора данных, для которой sanitization не предназначена. [Правила status в Document Service](https://docs.strapi.io/cms/api/document-service/status).
7. Что проверить перед релизом и что исправлять первым
Я начинаю с поиска мест, где Document Service используется рядом с внешним ответом. Команда ниже помогает найти кандидатов, но не заменяет чтение цепочки вызовов: запрос может находиться в сервисе, а отправка — в контроллере. Первыми разбираю доступные без пользовательского токена endpoints, затем ответы с populate и агрегаторы. Косметику JSON и переименование переменных спокойно откладываю. ```bash rg -n 'strapi\.documents|sanitizeOutput|sanitize\.output' src ```
Для учебной конфигурации «Вектора» задаю следующую матрицу приёмки. Проверять нужно настоящий HTTP-ответ, а сам санитайзер дополнительно проверять широкой фикстурой, содержащей закрытые атрибуты. Иначе тест способен пройти только потому, что fields вообще не загрузил секреты. Нужны и отрицательные проверки, и положительные: система, удалившая все статьи, тоже не выдаёт пароли, но задачу бизнеса не выполняет.
Если небезопасный endpoint уже работал, я сначала закрываю выдачу и оцениваю, какие данные могли уйти. Затем проверяю кэш ответов, логи и подключённые интеграции. Действующие секреты, попавшие наружу, требуют отдельной реакции; одного исправления контроллера недостаточно. Кэш для разных ролей тоже должен учитывать различие разрешений: очищенный ответ Integration нельзя раздать посетителю Public. После обновлений Strapi и изменений схем эту же матрицу запускаю повторно.
- Public: возвращаются две опубликованные статьи; reviewer равен null, внутренние атрибуты отсутствуют.
- Integration: доступны те же две статьи и displayName профиля; закрытые email и password отсутствуют.
- Широкая фикстура: очистка удаляет internalNote и accessCode, а также закрытые поля вложенного профиля.
- Контроль черновика: неопубликованная третья статья отсутствует у обоих клиентов.
- Любой непустой query, включая status=draft или populate, получает 400 согласно контракту примера.
- Отзыв разрешения article.find блокирует маршрут; отсутствие данных не маскируется успешным ответом с пустым массивом.
Частые вопросы
Достаточно поставить private: true?
Для прямого ответа из Document Service — нет. Настройка должна быть применена санитайзером перед отправкой данных клиенту.
Можно ограничиться fields?
Я использую fields для ограничения выборки, но сохраняю sanitization: она учитывает схему и права на relations. Внешний контракт дополнительно закрепляю DTO.
Нужно ли повторно очищать результат super.find(ctx)?
Штатное действие уже выполняет очистку. Если после него вы добавляете новые данные из внутренних источников, безопасность этих добавлений нужно обеспечить отдельно.
Почему reviewer исчез после sanitize.output?
Проверьте контекст auth, разрешение find целевого content-type и настройки приватности. Не возвращайте связь из raw только ради восстановления прежнего формата.
Я помогу проверить custom API на Strapi, настроить очистку ответов и закрепить проверки в процессе выпуска. Обратитесь в «АйТи-Фреш» через itfresh.ru: разберём ваши контроллеры, схемы и права интеграций.
Бесплатная консультация →

