Проверка выполняется повторяемо: dev/security-upload.sh (после входа dev/web-login.sh) — атаки на загрузчик изображений, ожидание «OK» у каждой строки. Любая новая точка входа (загрузка, AJAX, действие из списка, консольная команда) получает здесь запись и проверку.
Загрузчик изображений (MediaController::upload, ImageUploader)
| Угроза | Защита | Проверено |
|---|---|---|
| CSRF | токен формы Joomla только в теле POST (Session::checkToken('post')); GET-запросы отклоняются; ответ no-store, nosniff | без токена, токен в адресе, чужой токен, GET — отказ |
| Доступ без прав | вход в админку (иначе 403), core.create или core.edit компонента | гость — 403 |
| Подмена типа (PHP под видом картинки) | расширение из белого списка (настройки «Медиа» ∩ наш список), содержимое проверяется finfo и getimagesize | текст PHP как .png, файл .php — отказ |
| «Полиглот» (валидный PNG + PHP в конце) | файл с <?php отклоняется; расширение после сохранения только из белого списка; в папке загрузок .htaccess запрещает исполнение скриптов | PNG с хвостом — отказ |
| Обход путей | имя файла — транслит [a-z0-9-], расширение приводится к нижнему регистру; папка — только из списка (categories, icons, seo, products, gallery, common, иное → common); корневая папка настроек очищается от .., кириллицы, пустых сегментов | ../../evil.png, папка ../../../tmp |
| SVG (скрипты, события, внешние ссылки) | штатный санитайзер Joomla (enshrined/svg-sanitize, внешние ссылки убираются), отказ при @import/внешнем url(), <!ENTITY; SVG записывается уже очищенным; при прямом открытии — Content-Security-Policy: sandbox | onload через /, javascript: в виде HTML-сущности, @import |
| Гонка имён | имя занимается атомарно (fopen в режиме x) | — |
| Огромные изображения | площадь не более 80 млн пикселей, размер — меньший из «Медиа» и PHP | — |
| Конвертация WebP | При включённом image_convert_webp JPEG/PNG/AVIF преобразуются через Joomla\CMS\Image\Image; расширение назначается .webp только после проверки MIME и исходных размеров. SVG, GIF (анимация) и BMP не конвертируются; сбой обработки отклоняет загрузку. | Реализация добавлена; поведенческий сценарий загрузки ещё не прогонялся |
| Переход старых ссылок на WebP | Команда в списке товаров требует CSRF-токен и core.manage, обрабатывает ссылки порциями, ограничивает файлы корнем сайта, проверяет MIME/размер и не перезаписывает оригиналы или существующие WebP. Обновляет обложки, галереи, SEO-картинки, параметры категорий и корневых страниц; неподдерживаемые форматы оставляет без изменений. | На test.test команда завершилась успешно: последняя порция перевела 1 ссылку, ошибок 0; WebP проверен по MIME, исходный JPEG сохранён |
| Подстановка пути в форму | фильтр ImageUploader::filterPath у всех полей imageupload: только images/… с допустимым расширением, без .., схем (javascript:, http:), кавычек | javascript:, images/../configuration.php, http://…, .php, кавычки — обнуляются; допустимые пути сохраняются |
| Удаление чужого | ImageCleaner удаляет только файлы внутри папки загрузок и только если на них не ссылается другая запись | — |
| Утечка внутренних сообщений | непредвиденные ошибки — в журнал, пользователю общий текст | — |
Ограничения и задел: WebP включается отдельно для новых JPEG/PNG/AVIF; ранее загруженные ссылки меняются только при ручном запуске команды. SVG остаётся векторным, GIF сохраняет анимацию, BMP и неподдерживаемые Joomla форматы остаются исходными. Нарезка изображений и удаление скрытых метаданных пока не реализованы; на nginx .htaccess не действует — нужен запрет исполнения скриптов в images/ в настройках сервера.
Прочие точки входа
Действия списков и команды обслуживания (rebuild, cmdIndex, cmdProducts, cmdSales, пакетная обработка, избранное) проверяют токен и права (core.manage, core.edit/core.create); проверено чтением кода, в dev/security-upload.sh не входит.
Поиск и подсказки (SearchController::suggest, адрес поиска filter[search])
| Угроза | Защита | Проверено |
|---|---|---|
| Внедрение SQL через запрос | запрос очищается (управляющие символы, длина ≤ 100), слова нормализуются до букв и цифр, значения только параметрами (bind), в булев FULLTEXT-запрос попадают только буквы и цифры; артикул — экранирование % _ \ | запросы '<script>, %, a\0b, a'b — без ошибок и совпадений |
| Синонимы и варианты слов | правила синонимов из настроек (доступ только администратору) разбираются и нормализуются до букв и цифр, варианты (раскладка, транслитерация) строятся только из букв; число вариантов слова ≤ 8, слов запроса ≤ 8; все значения — параметрами | — |
| Перегрузка | предел числа подсказок (≤ 20), задержка и отмена запроса на клиенте, минимум символов | — |
| Скрытые товары | запрос присоединяется к обычному запросу каталога: опубликованные товары в опубликованных и доступных категориях | — |
| XSS в подсказках | скрипт подставляет значения через textContent, подсветка — DOM-узлами (mark), innerHTML не используется; ссылки и картинки — из ответа сервера (экранированные адреса) | запрос с разметкой выводится текстом |
| Кэш и индексация | ответ no-store, X-Robots-Tag: noindex; страницы списка с filter[search] — noindex, follow | — |
Раздел «Корзины» и ключи доступа (админка, ссылка в письме)
| Угроза | Защита | Проверено |
|---|---|---|
| Просмотр чужой корзины по ключу | /korzina?cart=<токен> работает только при праве dharmashop.orders.manage (иначе параметр игнорируется), только чтение, noindex, Referrer-Policy: no-referrer; просроченную корзину не открыть | гость с токеном видит свою (пустую) корзину; администратор — состав в режиме «только чтение», кнопки отключены, скрипт не подключён |
| Подбор ключа гостевого заказа | ключ — 128 бит (bin2hex(random_bytes(16))), сравнение в SQL по уникальному индексу, 404 при несовпадении; ключ действует только для гостевых заказов (user_id IS NULL); страница заказа с no-store и Referrer-Policy: no-referrer | верный ключ — 200, чужой/пустой/ключ другого заказа — 404 |
| CSRF на удаление и очистку корзин | POST + токен формы, право core.delete и dharmashop.orders.manage | удаление выбранной и очистка просроченных отработали, остальные корзины не затронуты |
| Секреты в админке | токены и ключи видны только пользователям с правом управления заказами | — |
Привязка гостевых заказов к аккаунту (OrdersModel::claimGuestOrders)
| Угроза | Защита | Проверено |
|---|---|---|
| Захват чужих заказов регистрацией с чужой почтой | по умолчанию привязка только при подтверждённой почте (активация по письму включена и пройдена, пользователь не заблокирован); режим «по любому совпадению» помечен небезопасным | при «Активации: нет» заказ не привязывается; режим «любой» привязал заказ с совпавшим e-mail (после проверки возвращён) |
| Лишние запросы | один индексированный UPDATE (по contact_email) раз за сессию | — |
Мои заказы (Site\Model\OrdersModel, вид orders)
| Угроза | Защита | Проверено |
|---|---|---|
| Чужие заказы | запрос ограничен orders.user_id = номер вошедшего пользователя (привязка параметром), гость получает пустой список и приглашение войти; состав и контакты в списке не читаются; просмотр заказа — прежняя проверка OrderModel | пользователь видит свои заказы и открывает их; чужой заказ — 404; гость на странице — перенос на вход |
| Кэш | страница no-store, private, noindex, nofollow | — |
Корзина (CartController: cart.add / update / remove / clear)
| Угроза | Защита | Проверено |
|---|---|---|
| CSRF | только POST и токен формы Joomla (Session::checkToken('post')); GET и чужой токен — отказ (JSON success: false) | запрос без токена, GET с токеном, чужой токен — отказ |
| Внедрение через идентификаторы и числа | product_id, variant_id, item_id приводятся к целым, все запросы — с привязкой параметров (bind); количество — число, округление до 3 знаков, проверка минимума/шага/максимума/остатка | product_id=205' OR 1=1-- → 205 (целое), отрицательный id — «товар недоступен», qty=abc — «меньше минимального», qty=1e9 — «нет в наличии» |
| Чужая корзина | корзина определяется токеном cookie (32 hex, HttpOnly, SameSite=Lax, Secure на HTTPS) или user_id; строки другой корзины недоступны (cart_id в каждом запросе) | — |
| Режим каталога | при catalog_mode = 1 все действия отклоняются, страница корзины — 404, модуль и скрипты корзины не подключаются | проверяет владелец (настройки через базу не меняются) |
| Скрытые товары | добавить можно только опубликованный товар в опубликованных и доступных категориях (как на странице товара) | снятый и несуществующий товар — «недоступен» |
Фрагмент мини-корзины (format=raw) | показ определяют настройки модуля, читаемые из базы по номеру mid (только опубликованный модуль mod_dharmashop_cart сайта; параметры показа через адрес не принимаются); классы кнопок очищаются (буквы, цифры, «-», «_») и экранируются; данные корзины — те же, что у страницы (по токену cookie) | запрос с чужим mid — значения по умолчанию; класс с кавычкой — вычищается |
Окно выбора товара (products&layout=modal) | админка, доступ как к списку товаров (core.manage), токен в адресе поля, значения выводятся через escape | запрос без входа — форма входа |
Посадочные страницы (com_dharmashop_landing)
- Админка: все задачи (
page.*,pages.*, включаяpages.check) проверяют токен и права компонента (core.manage,core.create,core.edit,core.edit.state,core.delete);pages.checkпринимает токен только в POST. Страницы вне админки не пишут данные. - Сайт: страница открывается, только если она опубликована (
state = 1), не помеченаbroken, доступна по уровню доступа и вся ветка категории-источника открыта посетителю (Service\CategoryAccess); иначе 404. Список страниц и редирект со страницы фильтра применяют те же условия. - Адреса: для неопубликованной, закрытой или несуществующей страницы маршрутизатор не раскрывает алиас (сегмент — номер, ответ — 404). Смещение
startза последней страницей списка товаров или списка страниц — 404 (без дублей адресов). - Условия страницы: строка запроса не вводится, значения выбираются из справочников и проходят
FilterService::state()(неизвестные алиасы отбрасываются, лимиты полей и значений общие с фильтром). - Фильтр на странице:
filter.refreshи AJAX-фрагмент (format=raw) — те же ограничения, что у категории (только модульmod_dharmashop_filterс указанным номером; вид должен быть из списка разрешённых). - Аудит безопасности 2026-10-13:
AUDIT_SECURITY_2026-10-13.md.
Исправления по аудиту 2026-10-13 (AUDIT_SECURITY_2026-10-13.md)
| Находка | Решение |
|---|---|
| С1. Привязка гостевых заказов по e-mail обходилась сменой e-mail в профиле | по умолчанию режим link: привязка только действием «Привязать заказ к аккаунту» на странице заказа (OrderController::claim, POST + токен, секретный ключ заказа из письма, заказ без владельца); автопривязка по e-mail оставлена как устаревшие режимы с предупреждением |
| С2. Песочница оплаты включалась при установке | плагин не включается установщиком; в списке способов оплаты опубликованная песочница показывается предупреждением; PaymentService::handle не засчитывает оплату с суммой, не равной сумме заказа |
| С3. Рост кэша фильтра от анонимных запросов | FilterCache::remember(..., $cacheable): запросы с текстом поиска и с более чем тремя условиями фильтра считаются без кэша (CategoryModel::cacheableRequest, ScopedFacets); проверено: 12 запросов с уникальным поиском и сочетаниями не создали ни одного файла |
| Н1. Алиасы скрытых записей в 301 | маршрутизатор ядра и посадочных страниц не отдаёт алиас неопубликованного товара, закрытой категории и закрытой страницы (сегмент — номер → 404) |
| Н2, Н3. Посадочные страницы | см. раздел «Посадочные страницы» выше |
| Н4. Гостевое оформление без защиты | Support\RateLimiter (файловый кэш, окна по часу): заказов с одного адреса и на один e-mail (настройки, 10 и 3 по умолчанию; считаются только проверенные заказы), невидимое поле-ловушка |
Н5. Различимые ответы payment.callback | единый ответ 400 для «нет заказа», «чужой способ» и неверной подписи |
| Н6. Лимит динамики доставки и оплаты по сессии | добавлен лимит по адресу клиента (RateLimiter) |
| Н8. DDL при каждом обращении к избранному | таблицы создаёт установка; при обращении выполняются два пустых SELECT, создание — только если таблиц нет |
И1. nosniff | все JSON-ответы витрины отдают X-Content-Type-Options: nosniff |
| И3. Токен в GET | pages.check — только POST |
| И5. Cookie избранного на каждой странице | cookie гостю ставится только при создании списка |
Гипотеза про qty | количество в корзине — конечное число от 0 до 1 000 000 (CartService::normalise) |
| Не исправлено | Н7 (обновление JS-библиотек доверяет CDN: нужен core.admin, токен и клик администратора; хеши в пакете — отдельная задача); И2 и серверные настройки — PRODUCTION_CHECKLIST.md |
Права доступа (C90, 2026-10-13)
| Угроза | Защита | Проверено |
|---|---|---|
| Менеджер товаров меняет структуру каталога | поля, группы полей, группы товаров — отдельные действия; контроллеры требуют их на входе (RequiresActionTrait, 403), виды и модальные окна проверяют то же; общие «Создать»/«Править» не заменяют | dev/acl-checks.php: поведение трейта с заглушками, наличие защиты у всех контроллеров и видов |
| Смена статуса любым, кто видит заказы | dharmashop.orders.status проверяется в order.changeStatus; форма смены статуса скрыта без права | код; acl-checks (действие объявлено и используется) |
| Опечатка в названии действия молча отключает проверку | acl-checks: каждое проверяемое в коде действие объявлено в access.xml, каждое объявленное dharmashop.* используется | dev/acl-checks.php |
| Потеря доступа менеджерами после обновления | AclMigration: новые действия получают группы прежних общих прав при повышении acl_version (параметр компонента, ставится установщиком; повторного переноса по содержимому правил нет, отозванное право не возвращается) | dev/acl-checks.php: 7 проверок миграции; сценарий оператора в dev/scenario |
| Лишние пункты в меню | пункты бокового меню по правам пользователя (прямой адрес всё равно защищён) | меню на test.test под суперпользователем |