Skip to content
LaravelBlog

laravel/framework: Address query issues in cursor pagination and whereRowValues()

LaravelBlogBot
LaravelBlogBot

Contributed by taylorotwell

Part of the framework v13.35.0 release

Pull request #61837, titled "Address query issues," bundles two small but security-relevant hardening fixes for the query builder. The first stops cursor pagination from treating plain column names that merely contain parentheses as raw SQL expressions — a potential SQL injection vector when sort columns come from user input. The second adds operator validation to whereRowValues(), so anything that is not a whitelisted comparison operator now fails fast with an InvalidArgumentException instead of being spliced into the compiled SQL. Neither change introduces new APIs, and both were previously only reachable through unusual or unsafe inputs, so applications upgrading from v13.34.0 should need little to no code changes.

Cursor pagination no longer trusts parentheses

cursorPaginate() builds its WHERE clause by replaying every orderBy() column and comparing it against the values stored in the cursor. To support aliased select expressions (for example, ordering by full_name where full_name was defined in selectRaw()), the builder resolves each order column back to its "original" name. Whenever that resolved name contained ( or ), the old code assumed it was a SQL expression and inserted it verbatim via new Expression(). That assumption is unsafe when the ordered column is just a plain identifier that happens to contain parentheses — which is exactly what a crafted value like id) or 1=1 or (id looks like.

The fix adds a guard in paginateUsingCursor(): a column is only wrapped as an expression when the resolved original name actually differs from the orderBy() value (meaning a real alias was mapped) and it contains parentheses. In both comparison branches, everything else is now treated as a quoted identifier.

// Still fully supported — the alias maps back to the expression:
User::query()
    ->selectRaw("concat(first_name, ' ', last_name) as full_name")
    ->orderBy('full_name')
    ->orderBy('id')
    ->cursorPaginate();

With the fix, a plain name is quoted like any other identifier, producing a WHERE clause consistent with the quoted order by clause:

select * from "foobar"
where ("id) or 1=1 or (id" > ? or ("id) or 1=1 or (id" = ? and "id" > ?))
order by "id) or 1=1 or (id" asc, "id" asc limit 17

whereRowValues() validates its operator

whereRowValues() compiles to a tuple comparison such as (last_update, order_number) < (?, ?), and the operator is interpolated directly into the statement. Until now — unlike where() — the method accepted any string as the operator, so a value like '< (1, 2) or 1=1 or (1, 2) <' would be pasted into the SQL untouched. The change reuses the builder's existing operator whitelist:

$builder
    ->select('*')->from('orders')
    ->whereRowValues(['last_update', 'order_number'], '<', [1, 2]); // fine

$builder
    ->select('*')->from('orders')
    ->whereRowValues(
        ['last_update', 'order_number'],
        '< (1, 2) or 1=1 or (1, 2) <',
        [1, 2]
    );
// InvalidArgumentException: Invalid operator passed to whereRowValues method.

Why this matters

Both changes enforce the same core rule of the query builder: identifiers and operators are never treated as raw SQL unless you explicitly opt in with DB::raw() or Expression. Anywhere an application maps a ?sort= query parameter to an orderBy() column — a common pattern in list endpoints — the cursor pagination path was previously a live injection vector, because the strings baked into the cursor's WHERE clause were not wrapped. Likewise, an operator sourced dynamically and handed to whereRowValues() could alter the shape of the compiled statement. These are defense-in-depth fixes: they do not add features, they remove sharp edges, and new tests cover both scenarios.

Upgrade impact

  • No API changes. Nothing is deprecated; both fixes are behavior tightening on edge-case inputs.
  • Cursor pagination: if you depended on a parentheses-containing string passed to orderBy() being inlined raw during cursor pagination, it is now quoted as an identifier. For genuine expressions, declare the expression in the select with an alias (as shown above) or use orderByRaw() — the alias-mapping path still works because the resolved name differs from the ordered column.
  • whereRowValues(): previously accepted-but-invalid operators now throw InvalidArgumentException. Use the standard operator set (<, >, <=, >=, =, etc.), the same list where() accepts.
  • Tests: if your suite asserts on the old raw SQL output for such columns, or expects invalid operators to pass silently, update those assertions.

Takeaways

  • Cursor pagination only inlines a column as an expression when it was genuinely resolved from an aliased select; user-controlled sort columns are now safely quoted.
  • whereRowValues() is aligned with where() and rejects non-whitelisted operators with InvalidArgumentException.
  • Review any place where user input feeds orderBy() columns or operator arguments; prefer explicit select aliases or orderByRaw() for expressions.
  • No configuration, migration, or code changes are required for applications using static column names and valid operators.

Sources

More from this release

Related Articles