Laravel Database Privileges
Laravel has some really good features for setting database connections - but oddly this isn’t spelled out in the documentation.
Databases (especially in Docker containers) often come by default with a single, powerful, user account.
As a result all too often people run Laravel without considering the principle of least privilege.
By following a few simple steps we can enhance security.
Least Privilege and Why it Matters
The Number one category of web application vulnerabilities is Broken Access Control
From OWASP Top 10
Access control enforces policy such that users cannot act outside of their intended permissions.
Violation of the principle of least privilege or deny by default, where access should only be granted for particular capabilities, roles, or users, but is available to anyone.
A01:2021 – Broken Access Control
Much of the focus of web application security is (rightly) on the code that we write - but in addition to that good security practice requires that we think about all the parts of our system.
Here is a snippet about database accounts.
The database accounts used by web applications often have privileges beyond those actually required or advisable. Allowing web applications to use sa or other privileged database accounts destroys the database server’s ability to defend against access to or modification of unauthorized resources. Accounts with db_owner equivalent privileges such as schema modification or unlimited data access typically have far more access to the database than is required to implement application functionality. Web applications should use one or more lesser-privileged accounts that are prevented from making schema changes or sweeping changes to or requests for data.
What this means for Laravel (and other apps like Drupal, Symfony, WordPress etc) is that the web user should only have access to the things it needs on teh database - usually things like SELECT, INSERT, UPDATE, DELETE.
This way if there is a weakness in you code - say an SQL injection vulnerability - it’s still going to be bad - but not as bad.
The attacker can still delete and/or replace your data.
But they won’t be able to make schema changes, load files, run database functions … and is generally much more limited in being able to find a weakness in the database layer to take their attack to the next level.
As web developers sometimes we think “if they get in - then it’s game over” but attackers don’t think like that. For them it is a case of - “right I’m through one layer of security - How do I go further? Can I get system access? Can I get root? Can I get to more systems on the network?”.
Different Database Users
What we want is different database users
- Super user
Can do anything on the database server, possibly including creating other users and other databases.
- Migration User
Can’t add users or databases - but can change the web database in any way needed.
- Web User
Limited to the functionality required by the website day to day. Read & write access to data - but no scheme changes. There might even be some tables you don’t wat this user to have full access to - perhaps a log table that should be write only.
How you set this up exactly will depend on your application - but most web applications have some process for database migrations so that when you push an update that needs schema changes these can be made by the application. What we want to aim for is separating out the extra permissions needed for migrations so that they are not given to the web user.
Laravel Database Configuration
Credit here to
@barbietunnie and @uxweb
Laravel allows us to setup multiple database connections - we can use these to specify two connections to the same database but different users.
in config/database.php
'default' => env('DB_CONNECTION', 'web'),
'connections' => [
'web' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'mydatabase'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_WEB_USER', 'web'),
'password' => env('DB_WEB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'migration' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'mydatabase'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_MIGRATION_USER', 'migration'),
'password' => env('DB_MIGRATION_USER_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
],
This sets up two connections with default values - provide passwords and actual values from your .env file
Postgresql Setup
I’m using Postgresql on Docker
(Worth noting I’m not very familiar with Postgres yet but I need it for this project)
the Postgres docker image runs an initialisation script which we can use to setup our users.
#!/bin/bash -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER $DB_WEB_USER WITH PASSWORD '$DB_WEB_PASSWORD';
CREATE user $DB_MIGRATION_USER WITH PASSWORD '$DB_MIGRATION_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO $DB_WEB_USER;
GRANT pg_read_all_data TO web;
GRANT pg_write_all_data TO web;
EOSQL
This creates the migration and web user with the required privileges. It is created the first time the database spins up - and doesn’t run again afterwards.
The actual usernames and passwords are passed in from in my docker-compose.yml file
postgis:
image: postgis/postgis:14-3.3-alpine
environment:
POSTGRES_DB: "mydatabasename"
DB_WEB_USER: ${DB_WEB_USER?err}
DB_WEB_PASSWORD: ${DB_WEB_PASSWORD?err}
DB_MIGRATION_USER: ${DB_MIGRATION_USER?err}
DB_MIGRATION_PASSWORD: ${DB_MIGRATION_PASSWORD?err}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?err}
volumes:
- .docker/postgis/init-user.db:/docker-entrypoint-initdb.d/init-user-db.sh
This reads the variables from .env and mounts my init script at the right location to get executed.
in .env I have
DB_WEB_USER=web
DB_WEB_PASSWORD=***
DB_MIGRATION_USER=migration
DB_MIGRATION_PASSWORD=***
POSTGRES_PASSWORD=***
Running Migrations
The default database connection is the web one so all web requests and artisan (cli) commands will use this ordinarily.
To run commands using the more powerful migration user we need to specify the other database connection
php artisan migrate --database=migration
I guess the parameter is named “database” for brevity - it is really a “database connection” and in this case is a different connection to the same database.
Summary
PHP is all about getting things done fast, and being accessible to new developers.
So I guess I can see why most documentation glosses over this step - it’s the sort of thing that gets in the way of getting started on the real work.
But getting a site hacked is just horrible.
Hackers are only getting better at what they do.
Us developers need to step up too.
Don’t forget Broken Access Control is the number one most critical web application security risk from 2021 (the most recent OWASP report at the time of writing - Jan 2023)