Securing Home Assistant with a Web Application Firewall (WAF)

Mon Jan 25 2021

If you expose Home Assistant to the internet, it's your responsibility to keep it secure. Here's how I implemented a Web Application Firewall to help improve security.

Securing Home Assistant with a Web Application Firewall (WAF)

In the world of home automation, the acronym WAF is often used to mean Wife Acceptance Factor. Personally, I prefer the gender-agnostic term Partner Acceptance Factor to avoid re-enforcing the stereotype that home automation is or should be male-dominated.

But I digress. Today we're going to be looking at a different type of WAF - the Web Application Firewall - and how it can help you to secure your smart home.

Security Issues

In January 2021, Home Assistant released two critical security bulletins and urged their users to upgrade as soon as possible. Vulnerabilities had been found not in Home Assistant's core, but in several custom integrations including the very popular Home Assistant Community Store (HACS).

Top Tip: if you use any custom integrations and haven't upgraded to the patched versions, I would highly recommend you do so. If for any reason you can't, then I'd urge you to consider blocking Internet traffic if you haven't already.

Security issues are a fact of modern life. Commercial, closed-source as well as community-driven open-source projects like Home Assistant are all vulnerable.

For what it's worth, I think the Home Assistant core development team handled the situation incredibly well. Since it's an open source project, they have no formal list of users, but they released an initial security bulletin on their website and social media channels, notifying people of a new security vulnerability and encouraging them to upgrade ASAP.

The bulletin was deliberately light on details, but being an open source project, it was easy to see from the code commits what the patch included. It, along with several other patches issues over the next week or so, introduced filtering to prevent common attack vectors such as filesystem traversal - which would have allowed an attacker to access sensitive files, including credentials.

Behind the scenes, the team also contacted developers of custom integrations that had been found to be vulnerable, urging them to patch their code. New, more secure versions of these integrations were released within days - a testament to the commitment of a group of volunteers, working on passion projects in their spare time. A subsequent security disclosure filled in the missing details about what had happened.

So while the immediate threat may have passed, it got me thinking. Can I do more to keep Home Assistant secure?

Securing Home Assistant

Home Assistant holds a powerful position in a smart home, and if an attacker were able to compromise it then the potential for damage would be serious. Someone with access to our system would not only have full control over all our devices (lights, heating, appliances, etc), but could view our cameras and would also know exactly where we live, when we were home (or not) and a lot more.

Worse still, Home Assistant could be used as a jump box to access the rest of our network. From here, an attacker could potentially chain together other exploits to access our data (or even hold it to ransom), add our devices to a botnet, use us as a spam relay node, and much more.

For it's part, the Home Assistant documentation urges people to consider the security of their system, and makes some good recommendations: regularly installing updates, locking down SSH access, using TLS, configuring secrets, and so on.

In fact, compared to many other home automation platforms, Home Assistant is in a much better position - it places a focus on making sure that "your data is your data", and can run entirely offline with no internet access whatsoever.

But we, like many others, want remote access to Home Assistant when we're not at home. We like being able to keep an eye on everything while we're away, or turn up the heating before we get home. We like being able to use integrations which rely on internet-connectivity like Google Assistant for voice control, AirVisual for air quality, and weather updates.

This means exposing Home Assistant to the internet, and all the threats that come along with it.

VPN Configuration

One option is to use a VPN. Access is available from anywhere in the world, as long as you connect to your VPN first. For many, this is a great option. Using something like WireGuard, this can be very easy to set up - and in fact, we do this too.

However there are some caveats. First, it means every person and every device must be set up to use the VPN while remote if they want to access Home Assistant. If you don't have your device configured to connect to the VPN automatically when you're not at home, you won't receive any notifications from the Home Assistant app. Another challenge is that 3rd parties relying on webhooks like Google Assistant won't work with this. There are solutions to these, but they can complicate things.

Another great option is to use Nabu Casa and the Home Assistant Cloud. For the less technically-inclined, this offers a very quick and easy way to get things set up. It's not free, but your $5 per month fee goes to support the continued development of Home Assistant. In fact, I know some people have a Nabu Casa subscription that they don't use, just as a way to donate to the project.

Site-to-Site WireGuard VPN

While we do have VPN access set up, we don't use it from our individual devices very often. Instead, we have a DigitalOcean droplet that's also connected to our VPN, running a reverse proxy to expose Home Assistant over the internet.

While this makes remote access very easy for us, it also makes remote access very easy for other people too! Our server is hardened and well locked down, and of course we have logins configured for Home Assistant. But there's still a risk.

Which is why these recent security bulletins really got me thinking about how I could improve the security of Home Assistant on the public internet.

Web Application Firewall

You're probably already familiar with the concept of a firewall. Most firewalls work at a relatively low level - managing access to and from specific IP addresses and ports. For example, your firewall might block inbound traffic on all ports except 22 (SSH), 80 (HTTP) and 443 (HTTPS).

But a firewall usually can't distinguish between a good connection and a bad connection (unless you're using something like DPI). It has no idea whether the HTTPS request on port 443 is a legitimate request to your application, or a hacker attempting to exploit a potential weakness.

This is where a Web Application Firewall (WAF) comes in. A Web Application Firewall typically acts at a higher level - in the OSI model it's known as layer 7, the application layer. It attempts to identify and block higher level threats - filesystem traversal, SQL injection, Cross-Site Scripting (XSS) and more.

These are similar things, in essence, to what the Home Assistant patches were trying to mitigate against. But rather than rely on the application to do it, why not instead rely on a dedicated piece of software whose sole purpose is just this one thing?

There are many Web Application Firewalls out there - everything from expensive on-premises hardware to cloud-based platforms. However as with all things, I tend to gravitate to open source solutions, and in the WAF arena the best known is ModSecurity.

Setting up ModSecurity

Disclaimer: I've never used ModSecurity before, but after much research, I decided it looked like it did exactly what I was after. I'm still learning so if you see things I could do better, please leave me a comment below!

ModSecurity started out as a module that could be integrated into Apache web server, but since then has evolved and versions are now available for Apache, nginx and IIS. Unfortunately some of the newer reverse proxy applications like Caddy and Traefik aren't currently supported.

Since ModSecurity itself is just a firewall, it needs some rules. The Core Rule Set (CRS) developed by the Open Web Application Security Project (OWASP) provides a generic set of rules to protect against the most common attacks while minimizing false positives.

So I set myself the goal of implementing ModSecurity with CRS as a simple reverse proxy layer in front of Home Assistant. There are several ways to run Home Assistant (we run it in a Docker container on a Proxmox VM on an Intel NUC, managed by Ansible) and even more ways to reverse proxy it (we use Traefik which is responsible for TLS termination using Let's Encrypt with Cloudflare DNS), so I wanted to create the simplest possible configuration to make it available to as many people as possible.

ModSecurity as nginx Reverse Proxy

To keep things simple, I decided to put ModSecurity behind Traefik so that it wouldn't have to deal with HTTPS traffic - and so I wouldn't have to configure it with my certificates!

There is already an existing Docker image that purports to offer this functionality with nothing but configuration through a few environment variables, but after some experimentation I found that it doesn't quite work properly - some necessary configuration is missing. There is at least one open issue on GitHub about it, and maybe on the basis of this experimentation I'll be able to submit a PR to fix it.

However, in the meantime, here's what I've done to get up and running. If you're already doing TLS termination and / or load balancing with Apache or nginx, then you may prefer a more direct integration of ModSecurity - but I hope this guide will still help. Likewise, I've relied on environment variables for passing configuration, primarily to keep things easy to follow, but it should be easy to adapt if you prefer Docker secrets or something else.

ModSecurity & nginx Configuration

I wanted to run this in Docker, and I already have several Docker services configured, so it was a case of adding the following service to Docker compose:

docker-compose.yaml

web_application_firewall:
  image: owasp/modsecurity-crs:3.3-nginx
  restart: unless-stopped
  environment:
    UPSTREAM: http://home.local:8123 # <-- replace this with the current URL of Home Assistant
  volumes:
    - ./default.template:/etc/nginx/conf.d/default.template
#    - ./exclusion-rules.conf:/opt/owasp-crs/rules/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf
  command:
    - "/bin/sh"
    - "-c"
    - "envsubst '$UPSTREAM' < /etc/nginx/conf.d/default.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"

If you're not running the web_application_firewall service in the same docker-compose.yaml file as Home Assistant and / or your existing reverse proxy, then be sure to attach the relevant Docker networks so that your containers can talk to each other.

The only configuration necessary is to update line 5 with the current URL of your Home Assistant installation. Line 8 is commented out for now, but we'll get to that later. You can then update your reverse proxy to point to this new service, rather than Home Assistant directly - in our case, using Traefik, that meant adding these labels:

labels:
- "traefik.enable=true"
- "traefik.http.routers.home-assistant.rule=Host(`home.example.com`)"
- "traefik.http.routers.home-assistant.entrypoints=https"
- "traefik.http.routers.home-assistant.tls.certresolver=my-cert-resolver"
- "traefik.http.services.home-assistant.loadbalancer.server.port=80"

Next, you'll need to add two configuration files for ModSecurity. The first is the nginx configuration referenced in docker-compose.yaml above - make sure to update docker-compose.yaml with the location of this file.

default.template

# this is needed for Docker's internal DNS resolution
resolver 127.0.0.11 valid=5s ipv6=off;

server {
  listen 80 default_server;
  listen [::]:80 default_server;

  server_name _;

  set $upstreamServer ${UPSTREAM};

  location / {
    proxy_pass $upstreamServer;

    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
  }
}

Note that there's no mention of ModSecurity in here, and that's because the Docker image is doing the heavy lifting for us, enabling ModSecurity in /etc/nginx/conf.d/modsecurity.conf. Our docker-compose.yaml file has overriden the entrypoint parameter to substitute some environment variables into default.template, but if you prefer you can easily configure this file manually and mount it at /etc/nginx/conf.d/default.conf in the Docker image directly.

By default, all CRS rules are enabled, and while I haven't seen any problems with Home Assistant yet, your needs might be different. So what if we need to disable a rule?

In the docker-compose.yaml file above, you'll want to uncomment line 8 and create a file called exclusion-rules.conf. This will be mounted into the image and allows you to disable individual rules - more on that in a minute!

Once this service is up and running, Home Assistant should only be accessed through the Web Application Firewall and not directly. We have Home Assistant running with network_mode: host because I haven't figured out how to make mDNS work with the OPNsense mDNS reflector otherwise (please let me know in the comments if you have any pointers on this!), but you may be able to lock it down with a regular firewall, or remove its port bindings in your Docker configuration.

Testing

I've only been running with this configuration for a few hours, but so far everything seems to be working fine. If I look at the Docker logs on my Web Application Firewall container, I can see all my traffic running through it.

So it's working, but is it actually doing anything?! Time to test!

Disclaimer: I'm testing using a trivial example of a Cross-Site Scripting attack. This is NOT the same type of exploit that was recently found in the vulnerable custom integrations but serves as a useful example of how a WAF behaves. However, the Core Rule Set also includes rules to protect against the kind of exploits recently announced by the Home Assistant core team.

We can use curl on the command line to see what response codes we're getting (I've replaced my Home Assistant domain in the following examples with home.example.com). First, a simple one - the Home Assistant home page:

$ curl -I -X GET https://home.example.com/
HTTP/2 200 
content-type: text/html; charset=utf-8
date: Mon, 25 Jan 2021 16:47:16 GMT
server: nginx/1.17.9
content-length: 3349

The -I flag says we only want to see the headers, but since Home Assistant responds with an HTTP 405 Method Not Allowed response to HEAD requests by default, we we use -X GET to issue a GET request but only keep headers. As exepcted, we get an HTTP 200 OK response back from the server - you can run it without the -I flag to prove you're really getting the correct HTML back (I did).

So let's try something naughty:

$ curl -I -X GET 'https://home.example.com/?message="><script>alert('Naughty!');</script>'
HTTP/2 403 
content-type: text/html
date: Mon, 25 Jan 2021 16:51:11 GMT
server: nginx/1.17.9
content-length: 153

Adding ?message="><script>alert('Naughty!');</script> is a primitive example of a Cross-Site Scripting (XSS) injection attack, and ModSecurity has blocked the request, returning an HTTP 403 Forbidden response. If we look at the logs from our ModSecurity Docker container, we see the following:

2021/01/25 16:52:09 [error] 20#20: *11978 [client 172.18.0.5] ModSecurity: Access denied with code 403 (phase 2). Matched "Operator `Ge' with parameter `5' against variable `TX:ANOMALY_SCORE' (Value: `15' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "80"] [id "949110"] [rev ""] [msg "Inbound Anomaly Score Exceeded (Total Score: 15)"] [data ""] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-generic"] [hostname "172.18.0.16"] [uri "/"] [unique_id "161159352940.724850"] [ref ""], client: 172.18.0.5, server: _, request: "GET /?message=<script> HTTP/1.1", host: "home.example.com"
172.18.0.5 - - [25/Jan/2021:16:52:09 +0000] "GET /?message=<script> HTTP/1.1" 403 153 "-" "curl/7.68.0" "192.168.0.150"

These two lines tell us what's going on. The first line is the error from ModSecurity, telling us that the request exceeded the Inbound Anomaly Score. The next line shows we were returned an HTTP 403 Forbidden error.

If you want more information, you'll need to look in the ModSecurity audit log which can be found in the Docker container at /var/log/modsec_audit.log. There is a LOT of information in this file, so I won't paste it all here (it's pretty obvious what it's telling you) but here are the key lines:

ModSecurity: Warning. detected XSS using libinjection. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-941-APPLICATION-ATTACK-XSS.conf"] [line "37"] [id "941100"] [rev ""] [msg "XSS Attack Detected via libinjection"] [data "Matched Data: XSS data found within ARGS:message: "><script>alert("Naughty");</script>"] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-xss"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/152/242"] [hostname "172.18.0.16"] [uri "/"] [unique_id "161159498078.208306"] [ref "v14,36t:utf8toUnicode,t:urlDecodeUni,t:htmlEntityDecode,t:jsDecode,t:cssDecode,t:removeNulls"]
ModSecurity: Warning. Matched "Operator `Rx' with parameter `(?i)<script[^>]*>[\s\S]*?' against variable `ARGS:message' (Value: `"><script>alert("Naughty");</script>' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-941-APPLICATION-ATTACK-XSS.conf"] [line "63"] [id "941110"] [rev ""] [msg "XSS Filter - Category 1: Script Tag Vector"] [data "Matched Data: <script> found within ARGS:message: "><script>alert("Naughty");</script>"] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-xss"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/152/242"] [hostname "172.18.0.16"] [uri "/"] [unique_id "161159498078.208306"] [ref "o2,8v14,36t:utf8toUnicode,t:urlDecodeUni,t:htmlEntityDecode,t:jsDecode,t:cssDecode,t:removeNulls"]
ModSecurity: Warning. Matched "Operator `Rx' with parameter `(?i:(?:<\w[\s\S]*[\s\/]|['\"](?:[\s\S]*[\s\/])?)(?:on(?:d(?:e(?:vice(?:(?:orienta|mo)tion|proximity|found|light)|livery(?:success|error)|activate)|r(?:ag(?:e(?:n(?:ter|d)|xit)|(?:gestur|leav)e|start|d (3146 characters omitted)' against variable `ARGS:message' (Value: `"><script>alert("Naughty");</script>' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-941-APPLICATION-ATTACK-XSS.conf"] [line "180"] [id "941160"] [rev ""] [msg "NoScript XSS InjectionChecker: HTML Injection"] [data "Matched Data: <script found within ARGS:message: "><script>alert("Naughty");</script>"] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-xss"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/152/242"] [hostname "172.18.0.16"] [uri "/"] [unique_id "161159498078.208306"] [ref "o2,7o27,8v14,36t:utf8toUnicode,t:urlDecodeUni,t:htmlEntityDecode,t:jsDecode,t:cssDecode,t:removeNulls"]
ModSecurity: Access denied with code 403 (phase 2). Matched "Operator `Ge' with parameter `5' against variable `TX:ANOMALY_SCORE' (Value: `15' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "80"] [id "949110"] [rev ""] [msg "Inbound Anomaly Score Exceeded (Total Score: 15)"] [data ""] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-generic"] [hostname "172.18.0.16"] [uri "/"] [unique_id "161159498078.208306"] [ref ""]

Here you can see that ModSecurity detected the potential Cross-Site Scripting (XSS) attack, and we actually triggered 3 separate rules: 941100, 941110 and 941160.

Disabling Rule

While I haven't seen it so far with Home Assistant, there's a chance it may block some legitimate requests because they look like they're doing something they shouldn't. If this happens, first ensure that it definitely is a legititmate request, and ideally see there's a way to avoid it - maybe report an issue on GitHub if it's a custom integration doing something you don't think it needs to be doing.

However, if you really need to allow something through, then the audit log has the information in you need to disable the rule. Remember in the docker-compose.yaml file earlier, there was that line commented out for specifying your custom exclusion-rules.conf configuration?

There's an example file on GitHub with lots more information about how to use this file, but creating (and mounting) that file with the following will disable the XSS rules that caught us out above.

exclusion-rules.conf

SecRuleRemoveById 941100
SecRuleRemoveById 941110
SecRuleRemoveById 941160

If I add this file, restart my Docker container, and then re-run my XSS attack, I now get:

$ curl -I -X GET 'https://home.example.com/?message="><script>alert("Naughty");</script>'
HTTP/2 400 
content-type: text/plain; charset=utf-8
date: Mon, 25 Jan 2021 17:19:11 GMT
server: nginx/1.17.9
content-length: 16

This time, checking the access logs from the WAF container show that it was not blocked, and there's nothing in the modsec_audit.log file either.

If you tested this too, don't forget to un-disable those rules!

Conclusion

Good security means having appropriate defenses to the threats you're protecting against. While I already had things locked down with strong credentials, SSH configuration, firewalls and more, the recent Home Assistant security bulletins were a reminder that you can never sit still.

I'm incredibly grateful that the Home Assistant development team take security seriously and were able to quickly patch the core to provide mitigation against exploits in custom integrations that they hadn't even built.

Home Assistant gives you enormous power to have full control of your home automation system, but with that comes the responsibility to secure it. The new Home Assistant request filters introduced in 2021.1.3 (and subsequent patches) are a great line of defense, but adding a Web Application Firewall like ModSecurity in front of Home Assistant adds even more robustness.

If you're already running Docker, then installing ModSecurity is incredibly simple and its container is currently consuming just 22MB of memory so the overhead there is tiny too. If you're not using Docker, then it's perfectly possible to run ModSecurity standalone - there are lots of great guides out there already for this.

This is the first time I've ever deployed ModSecurity, and other than the base Docker image not quite working properly, I've been impressed at how easy it was to get set up. The Core Rule Set (CRS) from OWASP provides a great set of defaults out of the box, and aims to protect web applications from a wide range of attacks, including the OWASP Top Ten - including SQL Injection, Cross Site Scripting and both Local & Remote File Inclusion.

Like many others, I'm always keen to learn and find more ways to keep my system secure. While this blog post was focused on securing Home Assistant, I'm keen to roll this out to protect other public-facing web applications - including this website!

If this guide was helpful to you, or you have some pointers to help make this even more secure, please leave a comment below! Stay safe, and keep automating!

Photo by Fábio Lucas on Unsplash

Previous postWinter RVing - January UpdateNext postWhy we shop at Costco