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.
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.
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.
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.
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
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.
In order to write a request log, we 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.
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 use your app server to probe other internal systems looking for vulnerabilities or 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, giving it access to encrypted S3 buckets or API keys for third party services from Parameter Store.
Putting it into practice
As you can see, there are lots of ways to 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 much more to security than just the app itself. This is a "defense in depth" approach. Security issues are inevitable, we need to limit their impact. Attackers must 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. Add an audit trail to identify the source of attacks, e.g. compromised user accounts or hostile internal users. Restrict access to data so that there is a data breach, we can identify exactly which data was leaked.