Package · mui-data-grid

Whitelist & safety

A table in the browser is a user-driven query builder. It sends field names, operators and values, and the server has to turn them into SQL without letting them turn into anything at all. This page is about where exactly the trust boundary runs and why it is drawn that way.

The problem

The naive server-side table looks like this:

php
// NEVER do this
$sql .= " ORDER BY {$request->sortModel[0]['field']} {$request->sortModel[0]['sort']}";

That is SQL injection through a column name. Worse, parameter binding cannot close it: a placeholder substitutes a value, and a column name and sort direction are not values — they are part of the query’s structure. The only workable defense is to check the incoming name against a list known in advance.

That is where the schema comes from. It is not “extra validation on top”; it is the only mechanism capable of closing this class of problem at all.

The trust boundary

Every part of the request has a source, and the source decides the treatment:

Data Source Where it goes How it is handled
field browser nowhere a lookup key into the schema
operator browser branch selection cast to an enum; unknown → 400
value browser into SQL always a bound parameter
sort browser ASC / DESC validated with #[In], else 400
page, pageSize browser LIMIT / OFFSET integers, range-checked by validation
a column’s SQL expression your code into SQL verbatim trusted
a sort expression your code into SQL verbatim trusted
defaultOrder() your code into SQL verbatim trusted

The first row is the key one. A field name from the request never enters the query text. It is only used as a key into an associative array of columns; what reaches SQL is the expression you wrote in the schema yourself.

text
{ "field": "authorName" }        →   looked up in the schema
                                   ↓ found
GridColumn::for('authorName', 'au.name')
                                   ↓ the right-hand side goes to SQL
au.name ILIKE :iqb0                  ← the request's name never got here

Which gives a rule worth stating on its own.

Never build a column expression from request data

Everything you pass to GridColumn::for(), sortable() and defaultOrder() enters the query without escaping — it is your SQL. A schema assembled from user input reinstates exactly the hole it exists to close.

The order of checks

One filter item goes through four steps, and the order matters:

text
1. the field is looked up in the schema
 not found                      → MuiGridException (400)

2. the field exists but filterable() was never declared
                                → MuiGridException (400)

3. the operator is checked against the FilterType set
 not in it                      → MuiGridException (400)

4. your filterUsing() resolver is called
 returned a Qb                  → that is used
 returned null                  → the standard operator mapping

The type gate sits before the resolver, deliberately: a user resolver is extra logic, not a replacement for the check. An operator the column’s type disallows never reaches your code, so it is not possible to write a resolver that accidentally weakens the guard.

Why filters are rejected but sorts are not

The two operations react differently to an unknown field:

Situation Filtering Sorting
field declared and enabled applied applied
field declared, operation not enabled 400 skipped
field not in the schema 400 skipped
operator outside the type’s set 400

The asymmetry comes from who produces these requests.

A filter is a deliberate act. The user entered a condition and expects a result. If the condition cannot be applied, showing an empty table lies to them: they conclude there are no matches when in fact the filter never ran. Applying it partially, in silence, lies too. Hence an explicit error.

A sort is UI state. DataGrid emits sortModel when columns are reordered, when they are hidden, when saved state is restored — including for fields not currently on screen. Answering 400 to a component’s transient state would break the UI for no reason. So unusable entries are dropped, and when none remain defaultOrder() applies.

The practical consequence for a developer: a name mismatch shows up differently. Mistype a table column’s field and the filter produces a visible error while the sort simply does nothing — clicking the header changes nothing at all. The second is easy to miss.

What the grid cannot bypass

User filters are added to the repository with andWhere(), which means they can only narrow the set:

php
$repo->where(Qb::eq('a.is_published', true));   // your condition
// … the grid then adds:
$repo->andWhere($filtersFromRequest);            // AND — not OR, not a replacement

A row excluded by your base condition cannot be reached from the browser — not by any combination of filters, logicOperator: "or" included: the filter model is wrapped in parentheses as a whole before being joined to your condition with AND.

This is exactly why scope — “mine only”, “my organization only” — belongs in the base query rather than in a schema column. A column is something the user can simply leave blank; a base condition is not.

Permission-gated columns

The schema is built per request, so it can depend on who is asking. That is the simplest way to hide a column from some users:

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'));
}

A user without the permission can neither see, filter, nor sort by views: the column is not in their schema, so a filter on it is a 400 and a sort is ignored.

A hidden column leaks through sorting

Even when a column is left out of the SELECT, leaving it sortable in the schema lets a user order the list by an invisible value and thereby reconstruct it — a classic leak (sorting by salary, for instance). Remove the column from the schema, not just from the projection.

Capping the page size

pageSize is capped at 10,000 by request validation. That guards not against an attacker but against an honest “give me everything” — a request that looks like pagination while dumping the whole table and, on a large set, taking down both the database and the worker.

If your endpoint has a lower sensible ceiling, declare it in your request subclass:

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

What the library does not do

Knowing the limits helps. The library does not:

  • verify that a column’s SQL expression exists in the database — an unknown-column error will come from the database at run time;
  • bound the cost of a query — a correlated subquery inside a column expression runs in both the page query and the COUNT;
  • perform authorization — who sees what is decided by you, through the schema and the base query;
  • prevent enumeration — if a user may filter by a field, they may probe values for it; rate limiting stays at the application level.

Next steps