Repository navigation
fix(security): allowlist полей в процессорах покупателя и пользователя (#908) - #917
Merged
Merged
Conversation
…ля и пользователя (#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, и комментарий больше не создаёт впечатление, что вопрос решён.
…соров (#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 сохраняет.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Описание
Процессоры покупателя и пользователя, доступные через менеджерский connector, отдавали в ответе учётные колонки: у
msCustomer—passwordиtoken, уmodUser—password,cachepwd,salt,remote_key,remote_data.Затронуты были четыре места, и три из них — за рамками исходного issue:
Customer\GetListComboboxcomboвозвращался полныйtoArray()System\User\GetListCustomer\GetListgetData()читает строки сырымfetchAll(),iterate()зовётprepareArray()вместоprepareRow()— фильтровать было негде. Страница грида, то есть хеши и токены всех покупателей сразуmsorder_listCustomer\Getcleanup()отдавал полныйtoArray()msorder_viewCustomer\UpdateUpdateProcessor::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.Тип изменений
Связанные Issues
Closes #908
Вопрос самих прав на эти процессоры здесь не решается и остаётся за #895: без
comboответ по-прежнему содержит PII покупателя (email, телефон, суммы заказов) и доступен любому с сессией админки. Комментарии в allowlistProcessorPermissionsSmokeTestпереписаны так, чтобы это было видно: учётные колонки закрыты, компромисс по правам стал уже, но не исчез.Как это было протестировано?
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\Updateпо-прежнему сохраняет: поле в базе меняется, урезан только ответ.Отдельно проверены экстраполя: завёл
msExtraFieldсclass = msCustomer(реальная колонка черезxPDOManager::addField), записал значение и прогнал процессоры до и после правки.composer ci:php/composer test,npm run lint:ci,composer stan/ GitHub Actions CI)Конфигурация тестирования:
c3c0929a)Скриншоты (если применимо)
Не применимо: Vue не затрагивается. Админка ходит в REST (
CustomersController), обращений к этим процессорам в репозитории нет — проверено грепом поsrc,config,vueManager/src,assets,_build,elements.Чеклист
composer stan/ CI jobPHPStan)npm run lint:ciдля Vue) — Vue не менялсяДополнительные заметки
Набор
CustomerManagerFields::FIELDSшире, чемformatCustomer(), на три поля:user_id,blocked_until,last_order_at. Они уже уходили этими процессорами и секретами не являются — убирать их в security-фиксе значило бы менять контракт сверх необходимого. Плюс, в отличие отformatCustomer(), набор расширяется экстраполями сайта.Свести оба списка в один источник стоит отдельной правкой, вместе с переводом
CustomersController::formatCustomer()на него — и там же решить, нужны ли экстраполя в REST-гриде админки.