Improving app security with the principle of least privilege

The security principle of "least privilege" means that apps should only have the permissions that they need to do their job, nothing more. If an attacker compromises your app, then they can't do anything outside of what the app would normally do. They may be able to break the application, but they can't use it as a stepping stone to attack other systems.

For example, a normal web application might need to be able to respond to HTTP requests, query and update a database, handle file uploads, and write log messages.

Separate app user and deploy user

The app has certain things that it needs to do at runtime, e.g. read and write files. Create a separate user for it to run under which only has those permissions. Use a different user account to deploy and manage the app.

The app account is not shared with anything else, so we can use user permissions to control which files the app can read and limit the system resources it can use. The app needs to be able to read its own source or binary files, but it doesn't need to be able to write them. Have the files owned by the deploy user and use group permissions to give the app access to them. One app should not be able to read and write another app's files. Tighten up permissions on directories to disallow read access to world.

When deploying the app, we need to be able to restart it. Give those permissions to the deploy user, not the app. Even better, instead of giving sudo to the deploy user, use file triggers to restart. This lets you deploy from e.g. a CI/CD system with limited trust.

Restrict permissions with systemd

By default, users are allowed to do quite a lot, e.g. open outbound network connections. If you don't need these rights, restrict them on a per-user or per-app basis. Systemd has many features to restrict what apps are allowed to do. It provides a relatively easy declarative interface to traditional Unix features like chroot(2). It also supports modern Linux features which provide more ways to restrict access in a fine grained way. You can use SELinux to restrict even more.

File uploads

In order to handle file local uploads, the app needs to be able to write to a location on the local disk. We can create a directory like /var/lib/myapp/uploads and make it writable by the app user. If all we need to do is receive data, it's not too bad, but we usually need to show data to other users. An attacker may try to upload a file to that directory, then convince the system to run it as code.

If our filters are not good, they might be able to upload a standalone PHP script. More tricky, if you allow users to upload an avatar image, then the attacker might embed some PHP code in their image file, then have web server execute it. Configure the web server to handle user content only as data.

The OS can help as well. Set the umask to keep the app from being able to create executable files. Make a separate file system for the uploads, then mount it with the noexec option, and the OS will stop executable files from running.

There are plenty of additional attacks where the data from one user can be used to attack another user, e.g. by embedding JavaScript, but that's at a different layer in the stack.

Using a Content Delivery Network

Even better is if we don't serve static files from the app server at all. Upload the files to an S3 bucket, then use a CDN like CloudFront to deliver the files. That is more secure, plus it's faster and cheaper than serving static files from an app server.

Signed URLs

Instead of giving the app permission to write files to the S3 bucket, give the user a signed URL which allows them to upload data directly to S3. The app doesn't need to process the files, so it doesn't need permission to read or write them at all. Again, faster and cheaper as well.

Database access

The app needs an account and password to be able to connect to the database. Some apps might just need read only access, but most need to update tables as well. We can still use the database permissions system to restrict what the app db user can do.

With PostgreSQL, remove role permissions like SUPERUSER, CREATEDB, CREATEROLE. Make the db schema owned by a different database user from the app user, then restrict the app user from creating or altering tables. When deploying the app, run database migrations as the user that owns the schema, not the app user.

In enterprise apps, it's common to share a database between multiple apps. Use database views to create read-only views on data, restricting data from sensitive columns.

Writing logs

In order to write a request log, we would normally give the app user write access to a directory like /var/log/myapp. That *also" means that an attacker can read potentially sensitive information from the logs or overwrite them, covering his or her tracks.

Instead of writing our own log files, use journald to manage logs. The app writes its logs to standard out, and systemd redirects them to the journal. The app can also use the journald logging API to write structured log messages, including metadata. We then pull that data out in real time and send it to a log aggregation system like Elasticsearch/Logstash/Kibana and generate real-time alerts on application errors or attacks.

Listening on a non-privileged port

Listening on TCP/IP ports below 1024 requires root permissions. In the early days of the Internet, it was common to have programs start as root, bind to the port, then (hopefully) drop privileges. The result was e.g. the "Morris worm" which exploited a buffer overflow in the "sendmail" mail server, then turned around to attack other machines.

We normally run our apps behind a proxy like Nginx. The proxy is still running with elevated permissions, though. Better is if we redirect traffic from port 80 to the app in the firewall using an iptables rule. Nothing runs as root.

Egress filtering

We normally use firewalls and security groups to restrict inbound traffic, but we can also use them to restrict outbound traffic. Set up firewall rules which allow only the intended activity of the app. If someone hacks your system and gets access to data, make it hard to get it out. Make it impossible to probe other internal systems from your app server looking for vulnerabilities. Keep your server from being used to attack other sites on the internet. Whitelist IPs to make sure that requests are coming from the correct place.

Restrict access with IAM roles

When your app is running in a cloud environment like AWS, it needs to access various resources like S3 buckets. Instead of putting AWS keys on your instance where they can be stolen, assign an instance role which implicitly gives it access to resources at runtime. Amazon is making it possible to access databases using IAM roles. You can restrict access by source IP, so even if an attacker gets your keys, they can't access resources from outside the instance. The IAM instance role can give it access to encryption keys in KMS. You can transparently get access to encrypted S3 buckets or access API keys for third party services from Parameter Store.

Putting it into practice

As you can see, there are lots of ways that we can restrict the deployment environment to make life harder for attackers. The key is to think about exactly what your application needs to do, then try to find ways to make sure that it can only do that.

There is a lot more to security than just the app itself. This is part of a "defense in depth" approach. Security issues are inevitable, we need to limit their impact. Attackers have to break multiple layers in order to compromise your app and make use of data and credentials that they obtain.

Similarly, we need to monitor systems so that we know when they are attacked or parts have been compromised. We add an audit trail to identify the source of attacks, e.g. compromised user accounts or hostile internal users. We restrict access to data so that there is a data breach, we can identify exactly which data was leaked.