Skip to content

Выравнивание ACL каталога на Fenom и hardening токенов - #757

Merged
biz87 merged 5 commits into
betafrom
fix/issue-755-catalog-acl-tokens
Sep 22, 2026
Merged

biz87 merged 5 commits into
betafrom
fix/issue-755-catalog-acl-tokens

Conversation

@Ibochkarev

Copy link
Copy Markdown
Member

Описание

После v1.13.0-beta1 сниппеты витрины оставались на анонимном RG-гейте, а Web API уже поднимал member-каталог. token/get отдавал отозванный токен из сессии, ResetPassword менял пароль inactive-клиенту, catalog API принимал ms3_token из query, а &sortby= на сниппете мог уходить в ORDER BY без whitelist.

Этот PR выравнивает Fenom с Web API через buildWhereFragmentForRequest / isVisibleForRequest, валидирует session-токен против БД, вешает TokenMiddleware на token/get, закрывает query-token elevation, проверяет isPasswordResetDenied на reset, дропает небезопасные части sortby до inject menuindex/options, инвалидирует facet/page ACL cache при смене групп. Для member-запросов страница получает cacheable=0, чтобы HTML не попал в общий ключ кэша ресурса.

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

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

Связанные Issues

Closes #755

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

cd core/components/minishop3
php -l src/Services/Catalog/CatalogResourceGroupVisibility.php
php -l src/Services/Catalog/CatalogSortbyQualifier.php
php -l src/Services/TokenService.php
php tests/CatalogResourceGroupVisibilityTest.php
php tests/CatalogSortbyQualifierTest.php
php tests/TokenMiddlewareQueryTokenTest.php
php tests/CustomerSessionContractTest.php
php tests/CustomerAuthRoutesTest.php
php tests/ResetPasswordAccessTest.php
./vendor/bin/phpunit \
  tests/Unit/Services/TokenServiceGenerateCustomerTokenTest.php \
  tests/Unit/Controllers/Api/Manager/CustomersControllerUpdateTest.php \
  tests/Unit/Controllers/Api/Manager/CustomerGroupsControllerAclCacheTest.php
# exit 0 — smoke OK; PHPUnit OK (12 tests, 44 assertions)
  • Ручное тестирование
  • Автоматические тесты (composer ci:php / composer test, npm run lint:ci, composer stan / GitHub Actions CI)
  • Тестирование на разных версиях PHP/MODX

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

  • MiniShop3: ветка fix/issue-755-catalog-acl-tokens (beta)
  • MODX: 3.x (unit/smoke без полной установки)
  • PHP: 8.2+

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

Не применимо (API / сниппеты / сервисы).

Чеклист

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

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

Member-aware resource-group visibility for storefront snippets matches Web API,
revoked session tokens are not reused, query-string ms3_token no longer elevates
ACL, reset denies inactive accounts, and unsafe snippet sortby parts are dropped.
@Ibochkarev Ibochkarev added bug Something isn't working php Pull requests that update php code priority: high Важно исправить в ближайшее время labels Sep 21, 2026
Replace ?? on $modx->resource with isset so nullCoalesce.initializedProperty
does not fire on the typed modx property.
PHPStan flags isset()/?? on CatalogResourceGroupVisibility::$modx. Read
resource into a local and guard with is_object instead.
@Ibochkarev
Ibochkarev requested a review from biz87 September 21, 2026 17:50
@biz87

biz87 commented Sep 22, 2026

Copy link
Copy Markdown
Member

Ветка сливается с актуальной beta (85e4dde8) без конфликтов. Гейт зелёный: PHPUnit 668 тестов, smoke 115/115, MySQL-группа на живой MariaDB 10.6, PHPStan версии CI без ошибок.

Основное проверил на живом MODX, не только по коду. Развернул ветку на dev (включая сниппеты в БД), закрыл товар группой ресурсов, завёл покупателя с группой клиентов → группой пользователей MODX и сравнил с beta:

Сценарий beta PR
Аноним, Fenom-витрина виден только публичный товар так же
Покупатель с токеном в cookie, Fenom-витрина виден только публичный видны оба
?ms3_token=<токен> на product/list member-каталог выдавался закрыт
Authorization: Bearer <токен> на product/list member-каталог member-каталог
GET token/get анонимом токен выдаётся токен выдаётся
cacheable после member-запроса 1 0
cacheable после анонимного запроса 1 1

Query-token elevation воспроизвёл на beta напрямую — чужой токен из строки запроса действительно расширял выдачу публичного каталога. Фикс рабочий, и что важно — он держится на самом resolveTokenFromRequest(), а не на миддлвари, которой у каталожных роутов нет. TokenMiddleware на token/get первичную выдачу не ломает: маршрут уже в списке публичных, токен там опционален.

Гейт isPasswordResetDenied() в ResetPassword семантику #748 сохраняет: временная блокировка по числу попыток сброс не запрещает, запрет только для неактивных и постоянно заблокированных. Регресса #747 нет.

Возвращаю из-за сортировки.

1. &sortby=RAND() в сниппетах перестаёт работать

Drop-режим CatalogSortbyQualifier отбрасывает любую часть, содержащую (, а ms3_products вызывает его всегда с dropUnmatched: true. Снял реальный SQL через general_log на dev:

&sortby= beta PR
pagetitle DESC, publishedon ORDER BY msProduct.pagetitle DESC, msProduct.publishedon так же
RAND() ORDER BY RAND() ORDER BY msProduct.id
FIELD(msProduct.id, 5, 3, 1) как есть ORDER BY msProduct.id

Случайная выдача товаров — ходовой приём для блоков «Смотрите также» и «Хиты», и ломается она молча: ни ошибки, ни записи в журнал, просто список внезапно отсортирован по id. То же самое с FIELD() для ручного порядка и любыми другими функциями.

Whitelist сам по себе нужен — &sortby=pagetitle; DROP TABLE x на beta уходит в pdoTools как есть, а в PR отбрасывается, это правильно. Но резать стоит по конструкции, а не по факту наличия скобки. Минимально: разрешить RAND() без аргументов; шире — белый список функций (RAND, FIELD, IFNULL, COALESCE, CAST) с проверкой, что внутри скобок только разрешённые поля, числа и строки.

2. Отброшенные части нигде не фиксируются

Помимо функций, drop-режим молча выбрасывает всё, что не поле ресурса, не поле msProductData, не объявлено через includeTVs / includeVendorFields / sortbyOptions и не имеет алиаса из ['msProduct', 'Data', 'Vendor']. Под это попадает сортировка по полю из пользовательского &leftJoin с собственным алиасом — распространённый приём на кастомных витринах. Когда все части отброшены, подставляется msProduct.id, и владелец сайта видит просто «сортировка сломалась».

Достаточно одной строки $modx->log(modX::LOG_LEVEL_WARN, ...) с перечислением отброшенного — тогда причина находится по журналу за минуту. Плюс имеет смысл добавлять в список разрешённых алиасов те, что пришли в &leftJoin / &innerJoin.

3. Member-каталог не работает на кэшируемых страницах после первого анонимного визита

Проверил на ms3.test страницей с кэшируемым [[msProducts]]:

Порядок запросов Результат
Аноним первым, затем покупатель покупатель получает анонимный список; сниппет не выполняется вовсе
Покупатель первым (кэш пуст) покупатель видит member-товар, страница в кэш не пишется
Аноним после покупателя видит только публичный, кэширует анонимную версию
Покупатель после этого снова анонимный список

Со стороны безопасности всё правильно: member-HTML в общий ключ не попадает, утечки анонимам нет — cacheable=0 делает ровно то, что заявлено. Но MODX отдаёт кэш страницы до выполнения сниппетов, поэтому в обычной жизни (аноним заходит первым практически всегда) member-каталог на кэшируемой странице не работает никогда.

В лексиконе это описано как «предпочтительны вызовы [[!ms3_products]]». По факту это не предпочтение, а обязательное условие: без ! фича не работает. Предлагаю переформулировать прямо — и заодно упомянуть в документации и CHANGELOG, иначе на поддержку придут вопросы «включил настройку, а member ничего не видит».

Мелочь

disablePageCacheForMemberCatalog() покрыт только проверкой наличия метода в исходнике (CatalogResourceGroupVisibilityTest.php), а не утверждением про cacheable. Механизм сверил с ядром вручную, работает, но от будущих правок тест не защитит.

Allow RAND/FIELD/IFNULL/COALESCE/CAST in snippet drop mode, warn on
dropped parts, accept leftJoin aliases, and state uncached snippets as
required for member ACL on cacheable pages.
@Ibochkarev

Copy link
Copy Markdown
Member Author

Адрес по комментарию ревью:

1. &sortby=RAND() / FIELD() / …

В drop-режиме CatalogSortbyQualifier больше не режет по факту (. Разрешены RAND() без аргументов, FIELD, IFNULL, COALESCE, CAST с проверкой аргументов (поля из whitelist / числа / строки; без вложенных вызовов и SQL-ключевых слов). Bare-поля внутри функций квалифицируются (FIELD(id, …) → FIELD(msProduct.id, …)). CASE / (SELECT …) / RAND(1) по-прежнему отбрасываются.

2. Лог отброшенных частей + leftJoin

ms3_products пишет LOG_LEVEL_WARN со списком отброшенных частей. Алиасы из &leftJoin / &innerJoin добавляются в allowedTableAliases через tableAliasesFromJoins().

3. Member + page cache

В setting lexicon (ru/en) формулировка сменена с «предпочтительны» на обязательное условие: на кэшируемой странице после анонимного хита member не увидит свой каталог без [[!ms3_products]] / [[!ms3_gallery]]. CHANGELOG не трогал (релизный цикл). Документацию docs.modx.pro можно синхронизировать при релизе.

Плюс: smoke-тест на disablePageCacheForMemberCatalog теперь утверждает cacheable=0, а не только наличие метода в исходнике.

Проверки:

php tests/CatalogSortbyQualifierTest.php
php tests/CatalogResourceGroupVisibilityTest.php
./vendor/bin/phpunit tests/Unit/Catalog/CatalogResourceGroupVisibilitySqlTest.php
# OK

Stop assigning a fresh array into the optional by-ref out-param up front;
only write list<string> when the caller actually passed the argument.
@biz87
biz87 merged commit beb49e3 into beta Sep 22, 2026
6 checks passed
@biz87
biz87 deleted the fix/issue-755-catalog-acl-tokens branch September 22, 2026 17:56
biz87 added a commit that referenced this pull request Sep 23, 2026
* fix(snippets): вернуть сортировку по опциям и полям производителя

Белый список sortby (#742/#757) ломал два рабочих сценария.

Ключ sortbyOptions, совпадающий с колонкой msProductData (weight, color,
size, price и другие), квалифицировался как Data.weight, после чего
подстановка выражения опции давала Data.CAST(...) — битый SQL и пустая
выдача. Документированный пример sortby=weight + sortbyOptions=weight:number
переставал работать. Явно объявленные имена (sortbyOptions, includeTVs,
includeVendorFields) теперь проверяются раньше колонок.

Зарегистрированный дефолт includeVendorFields='*' попадал в белый список
литералом vendor_*, поэтому sortby=vendor_name молча отбрасывался на обычном
вызове [[msProducts]]. Звёздочка разворачивается в поля msVendor.

* fix(snippets): тот же приоритет объявленных имён внутри аргументов функций

Порядок проверок правился только в qualifySimplePart, а у аргументов
разрешённых функций остался прежним: FIELD(color,'red','blue') с опцией color
превращался в FIELD(Data.color,...) — то есть регрессия сохранялась для
ручного порядка значений опции. Заодно исключаем vendor id из разворачивания
includeVendorFields='*' — он и так не входит в выборку.
@biz87 biz87 mentioned this pull request Sep 23, 2026
7 of 13 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working php Pull requests that update php code priority: high Важно исправить в ближайшее время

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] ACL каталога и токены после 1.13.0: сниппеты, token/get, reset

2 participants