Tangible Bytes

A Web Developer’s Blog

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.

Principle of Least Privilege

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)