Skip to content

fix(security): allowlist полей в процессорах покупателя и пользователя (#908) - #917

Merged
biz87 merged 3 commits into
betafrom
fix/908-combo-secret-fields
Oct 18, 2026
Merged

biz87 merged 3 commits into
betafrom
fix/908-combo-secret-fields

Conversation

@biz87

@biz87 biz87 commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Описание

Процессоры покупателя и пользователя, доступные через менеджерский connector, отдавали в ответе учётные колонки: у msCustomer — password и token, у modUser — password, cachepwd, salt, remote_key, remote_data.

Затронуты были четыре места, и три из них — за рамками исходного issue:

Процессор Как утекало Право
Customer\GetListCombobox без combo возвращался полный toArray() нет своего права
System\User\GetList то же самое нет своего права
Customer\GetList getData() читает строки сырым fetchAll(), iterate() зовёт prepareArray() вместо prepareRow() — фильтровать было негде. Страница грида, то есть хеши и токены всех покупателей сразу msorder_list
Customer\Get cleanup() отдавал полный toArray() msorder_view
Customer\Update ядро само возвращает объект: UpdateProcessor::cleanup() → success('', $this->object) → modError::process() → toArray(). Пустой апдейт работал как способ прочитать хеш пароля любого покупателя msorder_save

Все пять воспроизведены на стенде до правки.

Что сделано

Белый список вместо чёрного. В наборе это принятый подход, причём осознанный: CustomerPublicDto помечен как «fail-closed for new columns», CustomersController держит явные списки на чтение (formatCustomer()) и на запись ($allowedFields). Чёрный список fail-open: новая чувствительная колонка в ms3_customers утекла бы по умолчанию, пока кто-нибудь не вспомнит про список секретов.

Новый Services/Customer/CustomerManagerFields зеркалит formatCustomer() — то есть набор, который Vue-админка и так считает полным payload'ом покупателя. Через него проходят все четыре customer-процессора.

Секреты не доходят даже до объекта. getSelectColumns() в GetList и GetListCombobox получает список исключений ($exclude = true), поэтому хеш и токен не читаются из базы вовсе. Ограничение набора в PHP остаётся вторым рубежом — оно нужно, потому что GetList::getData() отдаёт строки сырым fetchAll().

Экстраполя покупателя едут вместе с белым списком. Колонка, зарегистрированная через msExtraField, в CustomerManagerFields::FIELDS не входит — fail-closed срезал бы её молча, а excludedColumns() ещё и не дал бы прочитать из базы. Между тем такие колонки живут в xPDO map (ExtraFields::addFieldToMap()), карту грузит плагин на OnMODXInit, и до правки эти процессоры экстраполя отдавали — то есть белый список сам по себе ломал бы работающую функциональность.

Решение то же, что в CustomerPublicDto::publicFieldKeys(): CustomerManagerFields::fieldKeys() расширяет базовый набор активными ключами из CustomerExtraFieldRegistry, а приватный NEVER_FIELDS не даёт экстраполю вернуть password или token обратно в ответ.

Чтобы не ходить в ms3_extra_fields на каждую строку грида, ключи резолвятся один раз на прогон — новый Processors/Customer/CustomerFieldAllowlistTrait (prepareRow() и prepareArray() вызываются построчно). В prepareQuery* ключи берутся отдельной строкой до getFieldMeta(): аргументы вычисляются слева направо.

System\User\GetList этого не касается — modUser не может быть хостом экстраполей, MigrationGenerator::canHostExtraField() требует класс из MiniShop3\Model\mysql\*.

System\User\GetList переведён на тот же приём: вместо списка секретов — белый список полей. Заодно перестали уходить hash_class, session_stale и remote_*.

Ветки combo не изменились — проверено на стенде, отдают прежние id + fullname и id + username + fullname.

Тип изменений

  • Исправление бага (non-breaking change)
  • Новая функциональность (non-breaking change)
  • Breaking change (изменение, ломающее обратную совместимость)
  • Рефакторинг (без изменения функциональности)
  • Документация
  • Другое (опишите):

Связанные Issues

Closes #908

Вопрос самих прав на эти процессоры здесь не решается и остаётся за #895: без combo ответ по-прежнему содержит PII покупателя (email, телефон, суммы заказов) и доступен любому с сессией админки. Комментарии в allowlist ProcessorPermissionsSmokeTest переписаны так, чтобы это было видно: учётные колонки закрыты, компромисс по правам стал уже, но не исчез.

Как это было протестировано?

cd core/components/minishop3
php tests/run-smoke.php          # OK smoke tests (128)
php vendor/bin/phpunit           # 860 tests, 3016 assertions, OK
cd ../../..
php core/components/minishop3/vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G <изменённые файлы>   # No errors

tests/Unit/Processors/SecretFieldsTest.php — поведенческий, 16 тестов: обе ветки обоих combo-процессоров, строка грида вместе с actions, fail-closed на неизвестной колонке (recovery_code не проходит), состав исключений для SELECT, проброс зарегистрированного экстраполя через pick() и через excludedColumns(), запрет на возврат секретной колонки под видом экстраполя, нормализация ключей и то, что ключи резолвятся один раз на прогон, а не на строку.

Проверено, что тесты падают без фикса: при откате снятия колонок валится testCustomerRowWithoutComboHidesCredentials, при откате проброса экстраполей — три теста, включая оба процессорных (testCustomerGridRowReturnsRegisteredExtraField, testComboProcessorReturnsRegisteredExtraField).

tests/ProcessorSecretFieldsSmokeTest.php — страховка от отката при рефакторинге процессоров.

Живая проверка на MODX (runProcessor, стенд 1.14.2-beta1), до и после:

до правки:
  Customer\GetList  → !! password = $2y$10$r61P3rQtUpq…, token = 5414d64c50ad099378…
  Customer\Update   → !! password = $2y$10$IBzGqx5AL/m…, token = 49cf84e1366c1c35fe…

после правки:
  Customer\GetList             → 18 ключей (17 полей + actions), секретов нет
  Customer\Update              → 17 ключей, секретов нет, запись в базу работает
  Customer\Get                 → 17 ключей, секретов нет
  Customer\GetListCombobox     → 17 ключей, секретов нет
  Customer\GetListCombobox combo=1 → id, fullname
  System\User\GetList          → id, username, class_key, active, primary_group, sudo, createdon, fullname, email
  System\User\GetList combo=1  → id, username, fullname

Отдельно проверено, что Customer\Update по-прежнему сохраняет: поле в базе меняется, урезан только ответ.

Отдельно проверены экстраполя: завёл msExtraField с class = msCustomer (реальная колонка через xPDOManager::addField), записал значение и прогнал процессоры до и после правки.

на beta (до белого списка):
  Get / GetList / GetListCombobox / Update → экстраполе есть, но и секреты есть

белый список без проброса:
  Get / GetList / GetListCombobox / Update → секретов нет, но экстраполе пропало

текущее состояние:
  Get / GetList / GetListCombobox / Update → экстраполе есть, секретов нет
  combo-ветки                              → id + fullname, как и было
  System\User\GetList                      → экстраполей не бывает, секретов нет
  • Ручное тестирование
  • Автоматические тесты (composer ci:php / composer test, npm run lint:ci, composer stan / GitHub Actions CI)
  • Тестирование на разных версиях PHP/MODX

Конфигурация тестирования:

  • MiniShop3: 1.14.2-beta1 (ветка beta, c3c0929a)
  • MODX: 3.1.2
  • PHP: 8.3.25

Скриншоты (если применимо)

Не применимо: Vue не затрагивается. Админка ходит в REST (CustomersController), обращений к этим процессорам в репозитории нет — проверено грепом по src, config, vueManager/src, assets, _build, elements.

Чеклист

  • Код соответствует стилю проекта
  • Добавлены/обновлены комментарии в сложных местах
  • Изменения не ломают существующую функциональность
  • Лексиконы добавлены на двух языках (ru/en) — новых пользовательских строк нет
  • PHPStan проходит без новых ошибок (composer stan / CI job PHPStan)
  • ESLint проходит без ошибок (npm run lint:ci для Vue) — Vue не менялся
  • Обновлён CHANGELOG.md (для значимых изменений) — запись добавляет maintainer при релизе

Дополнительные заметки

Набор CustomerManagerFields::FIELDS шире, чем formatCustomer(), на три поля: user_id, blocked_until, last_order_at. Они уже уходили этими процессорами и секретами не являются — убирать их в security-фиксе значило бы менять контракт сверх необходимого. Плюс, в отличие от formatCustomer(), набор расширяется экстраполями сайта.

Свести оба списка в один источник стоит отдельной правкой, вместе с переводом CustomersController::formatCustomer() на него — и там же решить, нужны ли экстраполя в REST-гриде админки.

biz87 added 2 commits October 18, 2026 19:49
…ля и пользователя (#908)

Customer\GetListCombobox и System\User\GetList ограничивали набор полей
только внутри ветки `combo`. Без этого параметра строка уходила целиком:
у msCustomer вместе с `password` и `token`, у modUser — с `password`,
`cachepwd`, `salt`, `remote_key` и `remote_data`.

Своего права у обоих процессоров нет — это зафиксировано в allowlist
ProcessorPermissionsSmokeTest как осознанный компромисс для combo-списков,
и опирался он на то, что строка порезана до id + fullname. В ветке по
умолчанию компромисс не выполнялся, а сама ветка доступна через connector
любому, у кого есть сессия админки.

Теперь учётные колонки снимаются независимо от `combo`, как это делает
ядро в Security\User\GetList::prepareRow(). Ветка `combo` не изменилась,
остальные поля в ответе остаются на месте.

Сверх issue тем же закрыт Customer\Get: процессор отдавал полный toArray()
покупателя каждому менеджеру с правом msorder_view — тот же класс утечки,
что #840 и #848, только со стороны менеджерского connector'а.

Список секретных колонок покупателя вынесен в msCustomer::SECRET_FIELDS
с помощником withoutSecrets(), чтобы у чтения был один источник правды.

Тесты: Unit\Processors\SecretFieldsTest — поведенческий, проверяет обе
ветки обоих процессоров и падает, если снятие полей убрать;
ProcessorSecretFieldsSmokeTest — страховка от отката при рефакторинге.
…t и Update (#908)

По итогам ревью. Прошлый вариант снимал колонки чёрным списком и закрывал
не все процессоры того же класса.

Две оставшиеся утечки — обе воспроизведены на стенде до правки:

- Customer\GetList отдавал хеш пароля и токен по всей странице грида сразу.
  Там getData() читает строки сырым fetchAll(), а iterate() зовёт
  prepareArray() вместо prepareRow(), поэтому фильтровать было негде;
- Customer\Update возвращал полную строку через ядро:
  UpdateProcessor::cleanup() отдаёт сам объект, modError::process()
  разворачивает его в toArray(). Пустой апдейт работал как способ
  прочитать хеш пароля любого покупателя правом msorder_save.

Чёрный список заменён белым. В наборе это принятый подход, причём
осознанный: CustomerPublicDto отмечен как fail-closed for new columns,
CustomersController держит явные списки на чтение и запись. Денилист
же fail-open — новая чувствительная колонка в ms3_customers утекла бы
по умолчанию. Новый CustomerManagerFields зеркалит formatCustomer(),
то есть набор, который Vue-админка и так считает полным.

Секретные колонки теперь не доходят даже до объекта: getSelectColumns()
получает список исключений, поэтому хеш и токен не читаются из базы.
Ограничение набора в PHP остаётся вторым рубежом.

System\User\GetList переведён на тот же приём — вместо приватного списка
секретов белый список полей, так что hash_class, session_stale и
remote_* больше не уходят.

Обоснование в allowlist прав переписано честно: учётные колонки закрыты,
но дефолтная ветка по-прежнему отдаёт PII покупателя без своего права —
это остаётся скоупом #895, и комментарий больше не создаёт впечатление,
что вопрос решён.
@biz87 biz87 changed the title fix(security): не отдавать хеш пароля и токен из процессоров покупателя и пользователя (#908) fix(security): allowlist полей в процессорах покупателя и пользователя (#908) Oct 18, 2026
…соров (#908)

CustomerManagerFields fail-closed, поэтому колонка, зарегистрированная через
msExtraField, молча выпадала из ответов Customer/Get, GetList, GetListCombobox
и Update: pick() её срезал, а excludedColumns() не давал прочитать из базы.
Колонки живут в xPDO map (ExtraFields::addFieldToMap), карту грузит плагин на
OnMODXInit — то есть до правки эти процессоры экстраполя отдавали.

CustomerPublicDto решает это мержем CustomerExtraFieldRegistry::activeKeys();
здесь тот же приём: fieldKeys() расширяет базовый набор ключами экстраполей,
NEVER_FIELDS не даёт экстраполю вернуть password или token.

Новый CustomerFieldAllowlistTrait резолвит ключи один раз на прогон —
prepareRow()/prepareArray() вызываются на каждую строку, иначе страница грида
давала бы запрос к ms3_extra_fields на строку.

System\User\GetList не затронут: modUser не может быть хостом экстраполей
(canHostExtraField() требует MiniShop3\Model\mysql\*).

Проверено на стенде: экстраполе возвращается всеми четырьмя процессорами,
секретов нет, combo-ветки прежние, Update сохраняет.
@biz87
biz87 merged commit 7f26ddb into beta Oct 18, 2026
6 checks passed
@biz87
biz87 deleted the fix/908-combo-secret-fields branch October 18, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Combo-процессоры покупателя и пользователя не режут поля, когда combo не передан

1 participant