Белый список и безопасность
Таблица в браузере — это управляемый пользователем конструктор запросов. Он присылает имена полей, операторы и значения, а сервер должен превратить их в SQL, не дав при этом превратить их во что угодно. Эта страница — про то, где именно проходит граница доверия и почему она проведена так.
Задача
Наивная реализация серверной таблицы выглядит так:
// НИКОГДА так не делайте
$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 уходит то выражение, которое вы написали в схеме сами.
{ "field": "authorName" } → поиск в схеме
↓ нашли
GridColumn::for('authorName', 'au.name')
↓ в SQL идёт правая часть
au.name ILIKE :iqb0 ← имя из запроса сюда не попалоИз этого следует правило, которое стоит вынести отдельно.
Не собирайте выражение колонки из данных запроса
Всё, что вы передаёте в GridColumn::for(), sortable() и defaultOrder(), попадает в
запрос без экранирования — это ваш SQL. Схема, построенная из пользовательского ввода,
возвращает ровно ту дыру, которую она призвана закрыть.
Порядок проверок
Один элемент фильтра проходит четыре шага, и порядок здесь принципиален:
1. поиск поля в схеме
не нашли → MuiGridException (400)
2. поле нашли, но filterable() не объявлен
→ MuiGridException (400)
3. оператор сверяется с набором FilterType
не входит → MuiGridException (400)
4. вызов вашего filterUsing()-резолвера
вернул Qb → используется он
вернул null → стандартное сопоставление оператораШлюз по типу стоит до резолвера. Это сделано намеренно: пользовательский резолвер — дополнительная логика, а не замена проверке. Оператор, не разрешённый типом колонки, до вашего кода не дойдёт, и написать резолвер, случайно ослабляющий защиту, не получится.
Почему фильтр отклоняется, а сортировка — нет
Реакция на неизвестное поле у двух операций разная:
| Ситуация | Фильтрация | Сортировка |
|---|---|---|
| поле объявлено и включено | применяется | применяется |
| поле объявлено, операция не включена | 400 | пропускается |
| поля нет в схеме | 400 | пропускается |
| оператор вне набора типа | 400 | — |
Асимметрия объясняется тем, кто порождает эти запросы.
Фильтр — осознанное действие пользователя. Он ввёл условие и ждёт результата. Если условие нельзя применить, показать пустую таблицу — значит соврать: пользователь решит, что совпадений нет, хотя на самом деле фильтр не сработал. Молча применить его частично — тоже ложь. Поэтому — явная ошибка.
Сортировка — состояние интерфейса. DataGrid шлёт sortModel при перестановке колонок,
при скрытии колонок, при восстановлении сохранённого состояния — в том числе для полей,
которых сейчас на экране нет. Отвечать 400 на переходное состояние компонента значит
ломать интерфейс на ровном месте. Поэтому непригодные записи отбрасываются, а когда не
осталось ни одной — применяется defaultOrder().
Практическое следствие для разработчика: расхождение имён проявляется по-разному. Если
вы опечатались в field колонки таблицы, фильтр даст видимую ошибку, а сортировка просто
не будет работать — клик по заголовку не изменит ничего. Второе легко пропустить.
Что нельзя обойти через грид
Фильтры пользователя добавляются к репозиторию через andWhere(). Это означает, что они
могут только сузить набор:
$repo->where(Qb::eq('a.is_published', true)); // ваше условие
// … грид добавляет:
$repo->andWhere($filtersFromRequest); // AND, не OR и не заменаСтроку, отсечённую вашим базовым условием, из браузера достать нельзя — никакой комбинацией
фильтров, включая logicOperator: "or": модель фильтров оборачивается в скобки целиком,
прежде чем присоединиться к вашему условию через AND.
Ровно поэтому область видимости — «только мои», «только моя организация» — должна быть в базовом запросе, а не колонкой схемы. Колонку пользователь может просто не заполнять; базовое условие — нет.
Колонки под правами доступа
Схема строится на каждый запрос, значит она может зависеть от того, кто спрашивает. Это самый простой способ скрыть колонку от части пользователей:
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 на уровне валидации запроса. Это защита не от
злоумышленника, а от честного «отдай всё разом» — запроса, который выглядит как пагинация,
но выгружает всю таблицу и на большом наборе кладёт и базу, и воркер.
Если для вашего эндпоинта разумный потолок ниже, объявите его в наследнике запроса:
public function __construct(
int $page = 0,
#[Min(1), Max(200)] int $pageSize = 25,
// …
) {
parent::__construct($page, $pageSize, $sortModel, $filterModel);
}Чего библиотека не делает
Полезно знать границы. Библиотека не:
- не проверяет, что SQL-выражение колонки существует в базе — ошибка про неизвестную колонку придёт от СУБД во время выполнения;
- не ограничивает стоимость запроса — коррелированный подзапрос в выражении колонки будет
выполнен и в основном запросе, и в
COUNT; - не занимается авторизацией — кто что видит, решаете вы, строя схему и базовый запрос;
- не защищает от перебора — если пользователю позволено фильтровать по полю, он может подбирать значения; ограничение частоты запросов остаётся на уровне приложения.
Что дальше
- Диалекты и индексы — вторая «внутренняя» страница: как выбирается написание совпадения и чего это стоит.
- Ментальная модель — та же граница, но коротко.
- Свои фильтры — где ваша логика встраивается в этот порядок.