Пакет · mui-data-grid

Белый список и безопасность

Таблица в браузере — это управляемый пользователем конструктор запросов. Он присылает имена полей, операторы и значения, а сервер должен превратить их в SQL, не дав при этом превратить их во что угодно. Эта страница — про то, где именно проходит граница доверия и почему она проведена так.

Задача

Наивная реализация серверной таблицы выглядит так:

php
// НИКОГДА так не делайте
$sql .= " ORDER BY {$request->sortModel[0]['field']} {$request->sortModel[0]['sort']}";

Это SQL-инъекция через имя колонки. Хуже того — её не закрыть привязкой параметров: плейсхолдер подставляет значение, а имя колонки и направление сортировки значениями не являются, они часть структуры запроса. Единственный рабочий способ — сверить присланное имя с заранее известным списком.

Отсюда и появляется схема. Она не «дополнительная валидация поверх», она — единственный механизм, который вообще способен закрыть этот класс проблем.

Граница доверия

Каждая часть запроса имеет свой источник, и от источника зависит обращение:

Данные Источник Куда попадают Как обрабатываются
field браузер никуда ключ поиска по схеме
operator браузер выбор ветки приводится к enum, неизвестный — 400
value браузер в SQL всегда связанный параметр
sort браузер ASC / DESC проверяется через #[In], иначе 400
page, pageSize браузер LIMIT / OFFSET целые, диапазон проверен валидацией
SQL-выражение колонки ваш код в SQL как есть доверенное
выражение сортировки ваш код в SQL как есть доверенное
defaultOrder() ваш код в SQL как есть доверенное

Ключевая строка — первая. Имя поля из запроса в текст запроса не попадает никогда. Оно используется только как ключ в ассоциативном массиве колонок; в SQL уходит то выражение, которое вы написали в схеме сами.

text
{ "field": "authorName" }        →   поиск в схеме
                                   ↓ нашли
GridColumn::for('authorName', 'au.name')
                                   ↓ в SQL идёт правая часть
au.name ILIKE :iqb0                  ← имя из запроса сюда не попало

Из этого следует правило, которое стоит вынести отдельно.

Не собирайте выражение колонки из данных запроса

Всё, что вы передаёте в GridColumn::for(), sortable() и defaultOrder(), попадает в запрос без экранирования — это ваш SQL. Схема, построенная из пользовательского ввода, возвращает ровно ту дыру, которую она призвана закрыть.

Порядок проверок

Один элемент фильтра проходит четыре шага, и порядок здесь принципиален:

text
1. поиск поля в схеме
 не нашли                       → MuiGridException (400)

2. поле нашли, но filterable() не объявлен
                                → MuiGridException (400)

3. оператор сверяется с набором FilterType
 не входит                      → MuiGridException (400)

4. вызов вашего filterUsing()-резолвера
 вернул Qb                      → используется он
 вернул null                    → стандартное сопоставление оператора

Шлюз по типу стоит до резолвера. Это сделано намеренно: пользовательский резолвер — дополнительная логика, а не замена проверке. Оператор, не разрешённый типом колонки, до вашего кода не дойдёт, и написать резолвер, случайно ослабляющий защиту, не получится.

Почему фильтр отклоняется, а сортировка — нет

Реакция на неизвестное поле у двух операций разная:

Ситуация Фильтрация Сортировка
поле объявлено и включено применяется применяется
поле объявлено, операция не включена 400 пропускается
поля нет в схеме 400 пропускается
оператор вне набора типа 400

Асимметрия объясняется тем, кто порождает эти запросы.

Фильтр — осознанное действие пользователя. Он ввёл условие и ждёт результата. Если условие нельзя применить, показать пустую таблицу — значит соврать: пользователь решит, что совпадений нет, хотя на самом деле фильтр не сработал. Молча применить его частично — тоже ложь. Поэтому — явная ошибка.

Сортировка — состояние интерфейса. DataGrid шлёт sortModel при перестановке колонок, при скрытии колонок, при восстановлении сохранённого состояния — в том числе для полей, которых сейчас на экране нет. Отвечать 400 на переходное состояние компонента значит ломать интерфейс на ровном месте. Поэтому непригодные записи отбрасываются, а когда не осталось ни одной — применяется defaultOrder().

Практическое следствие для разработчика: расхождение имён проявляется по-разному. Если вы опечатались в field колонки таблицы, фильтр даст видимую ошибку, а сортировка просто не будет работать — клик по заголовку не изменит ничего. Второе легко пропустить.

Что нельзя обойти через грид

Фильтры пользователя добавляются к репозиторию через andWhere(). Это означает, что они могут только сузить набор:

php
$repo->where(Qb::eq('a.is_published', true));   // ваше условие
// … грид добавляет:
$repo->andWhere($filtersFromRequest);            // AND, не OR и не замена

Строку, отсечённую вашим базовым условием, из браузера достать нельзя — никакой комбинацией фильтров, включая logicOperator: "or": модель фильтров оборачивается в скобки целиком, прежде чем присоединиться к вашему условию через AND.

Ровно поэтому область видимости — «только мои», «только моя организация» — должна быть в базовом запросе, а не колонкой схемы. Колонку пользователь может просто не заполнять; базовое условие — нет.

Колонки под правами доступа

Схема строится на каждый запрос, значит она может зависеть от того, кто спрашивает. Это самый простой способ скрыть колонку от части пользователей:

php
public function grid(ArticleGridRequest $request, bool $canSeeStats): MuiGridResponse
{
  $repo = ArticleRepository::instance('a')->select(
      'a.id, a.title' . ($canSeeStats ? ', a.views' : '')
  );

  $columns = [
      GridColumn::for('title', 'a.title')->filterable(FilterType::String)->sortable(),
  ];

  if ($canSeeStats) {
      $columns[] = GridColumn::for('views', 'a.views')
          ->filterable(FilterType::Number)
          ->sortable();
  }

  return MuiGrid::wrap($repo, $request, GridSchema::make(...$columns)->defaultOrder('a.id DESC'));
}

Пользователь без права не сможет ни увидеть, ни отфильтровать, ни отсортировать по views: колонки нет в его схеме, поэтому фильтр по ней — 400, а сортировка игнорируется.

Скрытая колонка — это утечка через сортировку

Даже если колонка не попала в SELECT, оставленная в схеме сортировка по ней позволяет пользователю упорядочить список по невидимому значению и тем самым его восстановить — классический канал утечки (например, отсортировать по зарплате). Убирайте колонку из схемы, а не только из выборки.

Ограничение размера страницы

pageSize ограничен сверху значением 10 000 на уровне валидации запроса. Это защита не от злоумышленника, а от честного «отдай всё разом» — запроса, который выглядит как пагинация, но выгружает всю таблицу и на большом наборе кладёт и базу, и воркер.

Если для вашего эндпоинта разумный потолок ниже, объявите его в наследнике запроса:

php
public function __construct(
  int $page = 0,
  #[Min(1), Max(200)] int $pageSize = 25,
  // …
) {
  parent::__construct($page, $pageSize, $sortModel, $filterModel);
}

Чего библиотека не делает

Полезно знать границы. Библиотека не:

  • не проверяет, что SQL-выражение колонки существует в базе — ошибка про неизвестную колонку придёт от СУБД во время выполнения;
  • не ограничивает стоимость запроса — коррелированный подзапрос в выражении колонки будет выполнен и в основном запросе, и в COUNT;
  • не занимается авторизацией — кто что видит, решаете вы, строя схему и базовый запрос;
  • не защищает от перебора — если пользователю позволено фильтровать по полю, он может подбирать значения; ограничение частоты запросов остаётся на уровне приложения.

Что дальше