{"id":13491518,"url":"https://github.com/sellonen/django-security-tips","last_synced_at":"2025-03-28T08:33:16.648Z","repository":{"id":215839512,"uuid":"54025448","full_name":"sellonen/django-security-tips","owner":"sellonen","description":"Learn and promote secure system administration tips and practices in the Django community","archived":false,"fork":false,"pushed_at":"2016-03-21T12:33:55.000Z","size":25,"stargazers_count":60,"open_issues_count":2,"forks_count":3,"subscribers_count":5,"default_branch":"master","last_synced_at":"2024-02-14T18:33:27.848Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"cc0-1.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sellonen.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2016-03-16T11:15:54.000Z","updated_at":"2023-03-05T07:46:52.000Z","dependencies_parsed_at":"2024-01-13T10:12:14.265Z","dependency_job_id":"34c8a7fb-eef0-43c6-ade3-4b51dd050fb8","html_url":"https://github.com/sellonen/django-security-tips","commit_stats":null,"previous_names":["sellonen/django-security-tips"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sellonen%2Fdjango-security-tips","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sellonen%2Fdjango-security-tips/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sellonen%2Fdjango-security-tips/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sellonen%2Fdjango-security-tips/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sellonen","download_url":"https://codeload.github.com/sellonen/django-security-tips/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":245996766,"owners_count":20707314,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-07-31T19:00:57.802Z","updated_at":"2025-03-28T08:33:16.316Z","avatar_url":"https://github.com/sellonen.png","language":"Python","funding_links":[],"categories":["Guidelines"],"sub_categories":["Other"],"readme":"# Django and PostgreSQL security tips and practices\n\n## Contents\n\n* [Database roles, schemas, and migrations](#database-roles-schemas-and-migrations)\n* [User passwords](#user-passwords)\n* [Firewall](#database-cluster-firewall)\n* [Database separation](#database-separation)\n* [Further reading](#further-reading)\n\n## Purpose and motivation ##\n\nThe aim of this guide/repository is to learn and promote secure system administration tips and practices in the Django community.\nMy motivation is that most articles that focus on getting a Django application up and running do not talk much about security, yet database security guides often feel too abstract and intimidating for newcomers.\n\nThe scope of the guide is yet to be defined and will depend on the people who will get involved.\nYour questions, feedback, and insight well be very welcome!\n\n## Before we begin..\n\n.. Make sure you have read the\n[Django Deployment Checklist](https://docs.djangoproject.com/en/dev/howto/deployment/checklist/)\nand\n[Security in Django](https://docs.djangoproject.com/en/dev/topics/security/).\n\nThis skeleton has been created by commands\n```sh\ndjango-admin startproject playproject\ndjango-admin startapp plaything\n```\n\n## Database roles, schemas, and migrations\n\nPostgreSQL has something called [schemas](http://www.postgresql.org/docs/current/static/ddl-schemas.html), which are a bit like folders in the file system.\nBy default, all tables are created in a schema called `public` where all new users/roles have rather wide permissions.\nHowever, it is advisable to confine your web application to a specific schema and grant it as few privileges as possible.\n\nTo get started, let's create a database and log into it.\n\n```sh\nsudo su postgres\ncreatedb playdb \u0026\u0026 psql playdb\n```\n\nWe will have no need for the public schema, so let's drop it.\n(Make sure it's not used by anyone!)\n\n```sql\nDROP SCHEMA public CASCADE;\n```\n\nWe'll have two roles, `djangouser` and `djangomigrator`.\nThe `djangouser` will be used by your application in production, and `djangomigrator` will be used to perform migrations.\nThe `djangouser` will need permissions to select, insert, delete, and update rows on all the tables.\nIn addition, she'll need to access the [sequences](http://www.postgresql.org/docs/current/static/functions-sequence.html) to calculate the id of new model instances.\nThe lesson to learn is that she should have *only* those privileges.\n\n**Why bother with two roles?**\n* When every user's permissions are as narrow as possible, you will have an easier time debugging the system when you suspect a security breach.\n* Not everybody is interested in your data. If `djangouser` can create tables, a successful attacker can use your database for their own purposes.\n* If an attacker can create [trigger procedures](http://www.postgresql.org/docs/current/static/plpgsql-trigger.html), those procedures will persist even after password rotation and you might not notice for a long time. Considerable harm and snooping will ensue.\n\nAs the `djangouser` will not be able to create or alter tables, we'll need another role for that purpose, `djangomigrator`, who will be the owner of the schema `playschema` of your django project `playproject`.\nIn addition, we'll set the [search path](http://www.postgresql.org/docs/current/static/runtime-config-client.html) of both users to `playschema`.\n\n```sql\nCREATE ROLE djangomigrator LOGIN ENCRYPTED PASSWORD 'migratorpass';\nCREATE ROLE djangouser LOGIN ENCRYPTED PASSWORD 'userpass';\nCREATE SCHEMA playschema AUTHORIZATION djangomigrator;\nGRANT USAGE ON SCHEMA playschema TO djangouser;\nALTER ROLE djangouser SET SEARCH_PATH TO playschema;\nALTER ROLE djangomigrator SET SEARCH_PATH TO playschema;\n```\n\n\nIn order to juggle between these two roles you can create a special settings file `migrator_settings.py` for your migrator.\nIt's nothing more than\n\n```python\nfrom .settings import *\nDATABASES['default']['USER'] = 'djangomigrator'\nDATABASES['default']['PASSWORD'] = 'migratorpass'\n```\n\nand then you'll be able to run:\n\n```sh\npython manage.py migrate --settings=playproject.migrator_settings\n```\n\nBy the way, make sure that `python manage.py migrate` really fails!\nWe are not quite done yet, though, because `djangouser` will, by default, not have any privileges on the newly created tables or sequences.\nBefore running `python manage.py runserver` you will have to say\n\n```sql\nGRANT SELECT, INSERT, DELETE, UPDATE ON ALL TABLES IN SCHEMA playschema TO djangouser;\nGRANT USAGE ON ALL SEQUENCES IN SCHEMA playschema TO djangouser;\n```\n\nFinally, you probably want the migrations process to be a single simple command. To do that, see for example the file `migrate.sh`.\n\n## User passwords\n\nThe default password management in Django depends on your database user, i.e. `djangouser` in our case, to have `SELECT` privileges in the table `auth_user`.\nGo ahead, try that by running\n\n```sql\nSELECT * FROM auth_user;\n```\n\nin your dbshell.\nThat's pretty scary in case you fall victim to an SQL injection attack.\n\nThe easiest way to mitigate that threat is to use state of the art hash functions, as explained in the [Django documentation](http://django.readthedocs.org/en/latest/topics/auth/passwords.html).\nHowever, allowing `SELECT` on your password hashes is fundamentally insecure and you might want to consider an external identity management solution.\n\nAlternatively, there is an approach I learnt from [tsavola](https://github.com/tsavola).\nPostgreSQL has something called SECURITY DEFINER functions which can perform certain activities with special privileges, a bit like `sudo` on Unix systems.\nThis makes it possible to revoke the `SELECT` privileges on your password hashes but still be able to compare them as a regular user.\nMore precisely:\n\n* Make an SQL function called `check_password()` that will take in a user ID and a hash, and will return true if the user's hash in the database matches the one in the arguments.\n* That function is defined by the database superuser and flagged as `SECURITY DEFINER`, so that inside the function it can `SELECT` the existing hashes.\n* Then `django.contrib.auth.models.User.check_password()` will, instead of taking the hash out of the database, simply call that function.\n* While not necessary, to improve readability, I have revoked all permissions from the password hashes and salts from `djangouser` and instead call SQL functions `get_salt()` and `insert_or_update_password()`.\n\nTo continue on our previous example, you'll perform the following actions in the database:\n```sql\nCREATE SCHEMA auth_schema;\nGRANT USAGE ON SCHEMA auth_schema TO djangouser;\n\nCREATE TABLE auth_schema.passwords(\n    uid bigint PRIMARY KEY,\n    pw_salt bytea,\n    pw_hash bytea\n);\n\nCREATE FUNCTION auth_schema.check_password(IN bigint, IN bytea, OUT bool) AS\n$$\n    SELECT exists(SELECT 1 FROM auth_schema.passwords WHERE uid = $1 AND pw_hash = $2);\n$$\nLANGUAGE SQL IMMUTABLE STRICT SECURITY DEFINER;\n\nCREATE FUNCTION auth_schema.get_salt(IN bigint, OUT bytea) AS\n$$\n    SELECT pw_salt FROM auth_schema.passwords WHERE uid = $1;\n$$\nLANGUAGE SQL IMMUTABLE STRICT SECURITY DEFINER;\n\nCREATE FUNCTION auth_schema.insert_or_update_password(IN bigint, IN bytea, IN bytea) RETURNS VOID AS\n$$\n    INSERT INTO auth_schema.passwords (uid, pw_salt, pw_hash) VALUES ($1, $2, $3) ON CONFLICT (uid) DO UPDATE SET pw_hash = EXCLUDED.pw_hash, pw_salt = EXCLUDED.pw_salt;\n$$\nLANGUAGE SQL VOLATILE STRICT SECURITY DEFINER;\n\n-- REVOKE ALL ON auth_schema.passwords FROM PUBLIC; -- I normally delete the public schema.\nALTER TABLE auth_schema.passwords OWNER TO postgres;\nREVOKE ALL ON auth_schema.passwords FROM djangouser;\n\nALTER FUNCTION auth_schema.check_password(IN bigint, IN bytea, OUT bool) OWNER TO postgres;\nALTER FUNCTION auth_schema.insert_or_update_password(IN bigint, IN bytea, IN bytea) OWNER TO postgres;\nALTER FUNCTION auth_schema.get_salt(IN bigint) OWNER TO postgres;\n\n```\n\nIn addition, you'll need to extend the User model ([read the docs](https://docs.djangoproject.com/en/dev/topics/auth/customizing/#extending-user)).\n\n\n```python\nfrom django.db import models\nfrom django.db import connection\nfrom django.contrib.auth.models import AbstractUser\nfrom django.contrib.auth.hashers import make_password\nfrom django.utils.crypto import get_random_string\n\nclass CustomUser(AbstractUser):\n    def make_random_password(self):\n        length = 35\n        allowed_chars='abcdefghjkmnpqrstuvwxyz' + 'ABCDEFGHJKLMNPQRSTUVWXYZ' + '23456789'\n        return get_random_string(length, allowed_chars)\n\n    def save(self, *args, **kwargs):\n        update_pw = ('update_fields' not in kwargs or 'password' in kwargs['update_fields']) and '$' in self.password\n        if update_pw:\n            algo, iterations, salt, pw_hash = self.password.split('$', 3)\n            # self.password should be unique anyway for get_session_auth_hash()\n            self.password = self.make_random_password()\n\n        super(CustomUser, self).save(*args, **kwargs)\n        if update_pw:\n            cursor = connection.cursor()\n            cursor.execute(\"SELECT auth_schema.insert_or_update_password(%d, '%s', '%s');\" % (self.id, salt, pw_hash))\n        return\n\n    def check_password(self, raw_password):\n        cursor = connection.cursor()\n        cursor.execute(\"SELECT auth_schema.get_salt(%d);\" % self.id)\n        salt = cursor.fetchone()[0]\n\n        algo, iterations, salt, pw_hash = make_password(raw_password, salt=salt).split('$', 3)\n        cursor.execute(\"SELECT auth_schema.check_password(%d, '%s');\" % (self.id, pw_hash))\n        pw_correct = cursor.fetchone()[0]\n        return bool(pw_correct)\n```\n\n\nAnd put\n\n```python\nAUTH_USER_MODEL = 'plaything.CustomUser'\n```\n\nin your settings.py.\nAlso, to add users in the admin, you'll need to subclass the default forms, [see the docs](https://docs.djangoproject.com/en/1.9/topics/auth/customizing/#custom-users-and-the-built-in-auth-forms).\n\nAs a result, only the superuser of your database will ever be able to see the password hashes once they have been saved.\n\n**WARNING:** this solution depends on the function `make_password()` to stay constant.\nFuture version of Django may for example increase the number of iterations it performs and thus cause the hash comparison to fail.\nMake sure you have a plan for that.\n\n\n## Database cluster firewall\n\nYour database should be accessible only from certain IP address(es).\nEven if you are not afraid of attackers, be afraid of yourself accidentally running `dropdb playdb` instead of `dropdb playdb_test`.\n\nFor example, on AWS, if you have a Virtual Private Cloud with CIDR 172.38.0.0/16 you can allow inbound TCP traffic on port 5432 from source 172.38.0.0/16.\nThat will allow connections from any machine in that VPC, so you may want to be even more restrictive.\n\n## Database separation\n\nPrivileges can be defined on a number of levels in PostgreSQL: e.g. row, table, schema, and database level.\nAll of them have their uses, but let's highlight the difference between database and other levels.\n\n* An attacker can change the row, table, or schema with SQL commands,\n* but to access a different database, a new connection has to be initiated.\n\nSo if something must be kept out of the reach of your web app but still in the same cluster, consider putting it in a different database.\n\n## Read-only replicas\n\nVery useful for security as well as performance. TODO.\n\n## Further reading\n\n* [IBM develperWorks: Total security in a PostgreSQL database](http://www.ibm.com/developerworks/library/os-postgresecurity/)\n* [OpenSCG: Security Hardening PostgreSQL](http://www.openscg.com/wp-content/uploads/2013/04/SecurityHardeningPostgreSQL.pdf)\n* [OWASP: Backend Security Project PostgreSQL Hardening](https://www.owasp.org/index.php/OWASP_Backend_Security_Project_PostgreSQL_Hardening)\n\n---\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsellonen%2Fdjango-security-tips","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsellonen%2Fdjango-security-tips","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsellonen%2Fdjango-security-tips/lists"}