Hardening Laravel on Kuberenetes

· Updated · 4 min read

Laravel is a very popular framework used to create a whole host of different applications. However, creating the code itself is just the beginning of our adventure. Later, we need to place and run it somewhere. While shared hosting might be sufficient for very simple applications, larger ones definitely need something more robust, like Kubernetes. In this post, I'd like to briefly introduce hardening Laravel for Kubernetes, which means adapting it to achieve the best possible level of security.

Why System Hardening is a Good Option?

At the very beginning, it's worth asking ourselves: why bother with hardening at all? It requires additional work and testing, and containerization itself already offers a much higher level of security than the "traditional" approach, where we run many services within the same operating system on a physical server. Yes, that's obviously true, but many tutorials or default configurations don't take into account, for example, the possibility of an attack on such an application. In such a case, an attacker might take control of it, be able to execute unauthorized code, and consequently take control of the entire pod. From there, the path is open to further attacks, for instance, on other services in our system - and since we didn't decide on hardening, we probably didn't think about appropriate network policies either.

Kuberenetes securityContext

Fortunately, Kubernetes offers a very easy way to limit access and the escalation of potential breaches for deployments and containers - this is the securityContext. With a few settings, we can enforce that processes in a given container run only as a specific user/group (and not root), we can restrict the ability to escalate privileges, and even change the original file system to read-only. This means that after the application is deployed, it will not be possible to modify its files within the container. This is a significant increase in the level of security and is definitely worth looking into.

Is Laravel ready for all of this? Although you won't find much on this topic in the official documentation, it turns out that yes - adapting it to a strictly configured securityContext shouldn't be a major problem. It all really comes down to configuring the appropriate mount points that are necessary for the framework to work correctly, and switching to a user other than root.

Non-Root User

There is one topic I cannot describe here because it depends on the specific case: the application image. It may be that in your project, you will use a separate deployment for Nginx or Apache and a separate one for PHP with Laravel. You can also use a much simpler solution like FrankenPHP, whose official documentation describes how to quickly switch to a non-root user. I have tested this solution myself and I definitely recommend it - it just works. On top of that, Caddy is much more pleasant to configure than PHP, and the additional features from FrankenPHP make deployment even faster and less complicated (and therefore, easier to maintain). So, I'm assuming that somewhere in your image, you are switching to another user that has a uid/gid set to 1000.

ARG USER=nonrootuser
RUN useradd ${USER}
(...)
USER ${USER}

At this stage, we could already use the basic capabilities of securityContext, i.e., force the use of a different user and block the ability to escalate privileges.

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  allowPrivilegeEscalation: false

Although combining runAsNonRoot with setting runAsUser and runAsGroup might seem redundant, it is a safe setting: there is no room for error, we enforce it from "both sides." This will also be useful to us later.

Read-Only Filesystem

In order for us to switch the file system to read-only mode, we need to mount several directories that must remain writable. Laravel uses them to save cache, compiled views, uploaded files, etc. In addition, we must not forget about the global /tmp - this directory can be used by PHP itself (and here we must keep in mind the setting from php.ini). In all cases, we will use emptyDir. To start, let's add volumes in our deployment yaml.

volumes:
  - name: temporary
    emptyDir: {}
  - name: bootstrap
    emptyDir: {}
  - name: storage-app
    emptyDir: {}
  - name: storage-logs
    emptyDir: {}
  - name: storage-tmp
    emptyDir: {}
  - name: storage-framework-cache
    emptyDir: {}
  - name: storage-framework-testing
    emptyDir: {}
  - name: storage-framework-views
    emptyDir: {}

Next, inside our container's configuration, we will mount them - I'm assuming here that the Laravel application is in the /app directory. If it's different, you will of course need to adjust this part to your needs:

volumeMounts:
  - name: temporary
    mountPath: /tmp
  - name: bootstrap
    mountPath: /app/bootstrap/cache
  - name: storage-app
    mountPath: /app/storage/app
  - name: storage-logs
    mountPath: /app/storage/logs
  - name: storage-tmp
    mountPath: /app/storage/tmp
  - name: storage-framework-cache
    mountPath: /app/storage/framework/cache
  - name: storage-framework-testing
    mountPath: /app/storage/framework/testing
  - name: storage-framework-views
    mountPath: /app/storage/framework/views

That's basically it, now all that's left is to switch the securityContext to use a read-only file system.

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true

Of course, if we are using any add-ons/extensions that require write permissions to other directories (e.g., the popular Laravel Tinker), we must take this into account and add them in both volumes and volumeMounts.

Dropping Linux Capabilities

If we are sure that our application/container does not need any additional capabilities, for example, because we have configured it to listen on a port other than 80 and lower than 1024, we can be tempted to completely remove the possibility of extending them.

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop:
      - ALL

In such a configuration, our system is very strongly secured. Not only is it no longer running as root, meaning it cannot, for example, install additional software, but it also cannot modify the original files or listen on privileged ports. Even if our application written in PHP turns out to be insecure/vulnerable in some way, we will still be protected within our infrastructure – and that is very good news.