fix(crud): bind the inline id and quote request values in getDataTablesFilter - #209
Open
jacquestvanzuydam wants to merge 2 commits into
Open
jacquestvanzuydam wants to merge 2 commits into
jacquestvanzuydam wants to merge 2 commits into
Conversation
Crud::route() registers six routes. The GET-by-id handler binds the inline
{id} as a parameter:
$object->load("{$object->getFieldName($object->primaryKey)} = ?", [$id])
The POST-by-id and DELETE handlers built the same clause by interpolating
it into the string instead:
$object->load("{$object->getFieldName($object->primaryKey)} = '{$id}'")
$id comes straight from $request->inlineParams, so its content reaches the
where clause unquoted and unescaped. Anything that is not a plain id — a
quote, a comment marker — changes the statement rather than being compared
as a value.
This makes the two handlers match the GET one. No behaviour change for an
ordinary id, and the binding path is the one the same method already uses a
few lines above, so nothing new is introduced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getDataTablesFilter() returns SQL fragments that the caller concatenates
into a statement, and every part of them is built from $_REQUEST. Several
of those parts went in as-is.
Values are now quoted as literals:
- the search box value, which was concatenated directly into
" like '%" . strtoupper($value) . "%'";
- the REGEXP pattern, likewise.
Identifiers, which cannot be bound, are validated instead:
- a column has to resolve through the ORM to a bare identifier and be a
field the ORM actually knows about, otherwise it is skipped. An ORM
that exposes no fields — a plain Tina4\ORM — keeps the previous
behaviour, so only the identifier shape applies there;
- the order direction is now asc or desc, nothing else;
- start and length are cast to int.
escapeLiteral() picks the connection's own escaping where the driver has
it (pg_escape_literal on PostgreSQL, backslash handling on MySQL, since a
backslash is an escape character there unless NO_BACKSLASH_ESCAPES is set)
and otherwise doubles the quote, which is what MSSQL and SQLite3 expect.
Two fixes come with it:
- upper() is applied to the concatenated columns. The search term was
already uppercased but the column was not, so on any engine with a
case-sensitive LIKE — PostgreSQL, and MySQL under a binary collation —
the search matched only data that was already uppercase. This is why a
DataTables search returns nothing on PostgreSQL.
- $columnsToSearch was assigned only inside `if (!empty($ORM->DBA))` but
used unconditionally, so with no connection the where clause was built
from an undefined variable and came out as a fragment with no column on
the left. The filter is now dropped in that case.
The one thing left as it was is the non-regex branch of a per-column
search, `$filter[] = $searchValue`. The comment above it documents that
branch as accepting arbitrary SQL, so changing it would break a documented
feature — I did not want to make that call unilaterally. It is worth a
look, because the value still arrives from the request. Happy to follow up
whichever way you prefer.
tests/CrudDataTablesFilterTest.php covers the above; six of its eight tests
fail without this change. It runs on the existing SQLite3 dev dependency
and asserts the generated where clause against a real query, so the SQL is
checked for validity and not just shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits against
Tina4\Crud, both about request input reaching a statement as SQL rather than as a value. They are independent — the first is two lines and can be taken on its own if the second needs discussion.Targeting
v2becauseCrud::route()only exists on this branch;mainandv3have moved toAutoCrud.1. Bind the inline id in the update and delete routes —
f1032b7route()registers six handlers. The GET-by-id one binds the inline{id}:The POST-by-id and DELETE handlers built the same clause by interpolation:
$idcomes from$request->inlineParams, so it lands in the where clause unquoted. This makes the two match the GET handler — same binding path, a few lines up in the same method, so nothing new is introduced and an ordinary id behaves exactly as before.2. Quote values and validate identifiers in
getDataTablesFilter()—13ef98bThe method returns SQL fragments the caller concatenates into a statement, and every part is built from
$_REQUEST. Values are now quoted as literals (the search box value, the REGEXP pattern). Identifiers, which can't be bound, are validated instead: a column must resolve through the ORM to a bare identifier and be a field the ORM knows about; the order direction isascordesc;start/lengthare cast to int.escapeLiteral()uses the connection's own escaping where the driver offers it —pg_escape_literalon PostgreSQL, backslash handling on MySQL (a backslash is an escape character there unlessNO_BACKSLASH_ESCAPESis set) — and otherwise doubles the quote, which is what MSSQL and SQLite3 expect.Backwards compatibility: an ORM that exposes no fields (a plain
Tina4\ORM) gives an empty allow-list, in which case only the identifier shape applies and existing behaviour is preserved.Two fixes that come with it
upper()on the columns as well as the value. The search term was already uppercased but the column was not, so on any engine with a case-sensitiveLIKEthe search matched only data that happened to be uppercase. This is why a DataTables search returns nothing on PostgreSQL:upper()is standard SQL and the existing MySQL/MSSQLconcat()branch is untouched.An undefined variable.
$columnsToSearchwas assigned only insideif (!empty($ORM->DBA))but used unconditionally, so with no connection the where clause was built from an undefined variable and came out as a fragment with nothing on the left of thelike. The filter is now dropped in that case.One thing I deliberately left alone
The non-regex branch of a per-column search:
The comment documents this as accepting arbitrary SQL, so escaping it would break a documented feature and that is not my call to make unilaterally. It is worth a look though, because the value still arrives from the request. Happy to follow up whichever way you prefer — escape it, or gate the passthrough behind an opt-in.
Testing
tests/CrudDataTablesFilterTest.phpis new: 8 tests, and 6 of them fail without these changes. It uses the existingtina4php-sqlite3dev dependency and asserts the generated where clause by running it as a real query, so the SQL is checked for validity rather than just shape.Full suite: 40 tests, 1 failure —
ResponseTest::testRender, which fails identically on unmodifiedv2, so it is unrelated.composer lintclean (39 files).Context
Found while auditing a production app pinned to
tina4phpv2.0.93. We have been carrying a localCrudsubclass with these fixes for a while; this is an attempt to give them back so we can drop the override.🤖 Generated with Claude Code