Laravel restricting unfiltered queries

Laravel eloquent is a very powerful ORM. I came across a situation for a project of mine long time ago. There was a table api_logs which basically logged all API requests and responses. Evidently the table grew exponentially. One of the developers created an analytics module in which he forgot to add a where clause. It queried millions of rows at a time on production. It was a silly mistake but server was overwhelmed and you can imagine the rest of story (I can tell you it is not that good).

I imagined if there was a way to just throw an error if any function queries all rows from a table like api_logs. Two advantages :

  • Error will be thrown during the development cycle where it's like an eye opener for the developer to make sure he adds filters.
  • If the develolper pushed the code to live by mistake, someone requesting the page will get the error page. I will be still having advantage with x% of customers getting an error rather than 100% of customer not using website when memory is exhausted. (If that query was in the header or footer on every page then basically it's over haha!)

Implementation :

Short and simple, nothing fancy. We will just intercept an eloquent model for every query and check if it has a where clause. The best place for that is boot method of the model. I wish laravel has retreiving observer just like created, updated etc, which intercepts the query before it hits the database connection. (I am guessing as observers basically observes the state of a model instance, the state prior to retreiving is ideally an inception step of preparing collection of models/model and not yet a prime state to observe. And hence no such observer yet available.)

We can however use \DB::listen() listener as follows :

<?php

namespace App\Model;

use Illuminate\Database\Eloquent\Model;

class ApiLog extends Model
{
    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'api_logs';


    /**
     * The "booting" method of the model.
     *
     * @return void
     */
    public static function boot(){

        parent::boot();

        \DB::listen(function($query) {
            //
        }); 
    }
}

Note that for Laravel version 5.1 and lower, the parameters of listen callback are $sql, $bindings, $time instead of just $query;

Now we have an listener, we need to get the query and check if it is selecting all rows. We will do it by regex on the raw sql.

<?php

    \DB::listen(function($query) {

        $table = (new self)->table;
        $pattern = "/from(\s)*?`?$table`?(\s)*$/m";

        if(preg_match($pattern, $query->sql)){
            throw new \Exception("Error : Running unfiltered queries on `$table` table is not allowed!");
        }
    }); 
}

Above preg match will catch all queries which do not have any clauses after the table syntax. Those can be select, update or delete queries.

Let's make this little more manageable by creating a trait and then using it in model. Create RestrictsUnfilteredQueries.php :

<?php 

namespace App\Model;

trait RestrictsUnfilteredQueries
{
    /**
     * Restrict unfiltered queries
     * 
     * @return void
     * @throws \Exception
     */
    public static function restrictUnfilteredQueries()
    {
        \DB::listen(function($query) {

            $table = (new self)->table;
            $pattern = "/from(\s)*?`?$table`?(\s)*$/m";

            if(preg_match($pattern, $query->sql)){
                throw new \Exception("Error : Running unfiltered queries on `$table` table is not allowed!");
            }
        }); 
    }
}

Using in the model's boot method :

<?php

namespace App\Model;

use Illuminate\Database\Eloquent\Model;
use App\Model\RestrictsUnfilteredQueries;

class ApiLog extends Model
{
    use RestrictsUnfilteredQueries;

    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'api_logs';


    /**
     * The "booting" method of the model.
     *
     * @return void
     */
    public static function boot(){

        parent::boot();

        self::restrictUnfilteredQueries(); 
    }
}

And done! Now your server RAM will thank you forever for this ;)

Note that this will not work when you query using DB facade like DB::select() or DB::table()->get().

You may alternatively cater this as well by having DB::listen() inside a service provider's boot method. It will listen to all sort of queries. However, it might be too much to listen all queries.

There might be better way of implementing the same, I did it this way considering the urgency of situation. It is still there in project since then and working perfectly.

 
 
By : Mihir Bhende Categories : laravel, php Tags : laravel, php, security, slowloading