Content-Security-Policy for Home Assistant

Content-Security-Policy is a security feature (MDN Web Docs) in modern web browsers that restricts the kind of content that helps to protect against certain types of attacks, such as Cross-Site Scripting (XSS) attacks. Since my Home Assistant has significant access to my home network and is reasonably well-known, I wanted to take some steps to protect against malicious actors using XSS or other injection attacks to taking over my network. In addition, there have been a few different CVEs (HA Security Disclosures) in Home Assistant that allowed for XSS),

In this blog post, I walk through the steps I took to design a CSP header and fix bugs I found along the way.

CSP won’t protect against all attacks, but it’s one tool as part of a layered security system to protect your system. CSP works by explicitly identifying all sources of JavaScript, CSS, XHR requests, etc. and explicitly listing them in an HTTP response header sent to the browser.

My Stack

I run Home Assistant inside Kubernetes which is fronted by ingress-nginx. This is important because I attach the CSP header into the response using ingress-nginx. Home Assistant itself does not expose a config value to set this header in the http integration. Using Kubernetes isn’t required,

If you’re using Kubernetes like me, then go to the HA Ingress and start setting the header like below. Right now, the headers are defaults, but we’ll fill those in.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  apiVersion: networking.k8s.io/v1
  kind: Ingress
  metadata:
    annotations:
      # AuthN/Z handled by HA itself
      nginx.ingress.kubernetes.io/enable-global-auth: "false"
+     nginx.ingress.kubernetes.io/configuration-snippet: >
+       add_header Content-Security-Policy "default-src 'self'";
    name: homeassistant
    namespace: smarthome
  spec:
    ingressClassName: external-nginx
    rules:
    - host: ha.example.com
      http:
        paths:
        - backend:
            service:
              name: homeassistant-headless
              port:
                number: 8123
          path: /
          pathType: Prefix
    tls:
    - hosts:
      - ha.example.com

A vanilla nginx config will look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  server {
    listen 80;
    server_name ha.example.com;

+   add_header Content-Security-Policy "default-src 'self'";

    location / {
      proxy_pass foo:8123;
    }
  }

Traefik can use this.

A basic header

To start, I opened up my browser dev tools, reloaded the page, navigated around, then noted down all the different URLs that were loaded. Then I went to a CSP Generator. Most things load from the same HA endpoint, but things like maps and HCAS will load from external origins which must be explicitly permitted.

The header will look something like this:

  • default-src/Default Source - Set this to 'self' as a fallback grant if a request doesn’t match another directive.
  • script-src/Script Source - Set this to 'self' 'unsafe-inline' 'unsafe-eval'. This directive is the most important because it scripts are what make XSS attacks useful (more on the risks below.)
  • style-src/Style Source - Set this to 'self' 'unsafe-inline'
  • image-src/Image Source - Set this to 'self' data: https://basemaps.cartocdn.com https://*.basemaps.cartocdn.com https://brands.home-assistant.io https://raw.githubusercontent.com. cartocdn.com is used for Home Assistant’s maps, brands.home-assistant.io is used to load the integration icons, and raw.githubusercontent.com is used in HACS to view images for repositories. Make sure to add any other URLs that you’ve identified in the dev tools For example, I use the hass-tryfi integration, so I needed to add https://barkinglabs-media.s3.amazonaws.com to this header.
  • connect-src/Connnect Source - Set this to 'self' data: https://brands.home-assistant.io https://raw.githubusercontent.com. Interestingly, since HA uses a Service Worker the initial request to populate the Service Worker cache is considered a connect, so if an image isn’t loading, try adding it to the connect-src too.
  • frame-ancestors/Frame Ancestors - Set this to 'none'. Home Assistant shouldn’t be embedded in an iframe. Replaces the X-Frame-Options header, see CVE-2023-41897 and GHSA-935v-rmg9-44mw.
  • report-uri/Report URI - Optional. I use the Sentry integration and collect CSP reports to investigate issues using this. Example: https://sentry.example.com/api/12345/security/?sentry_key=abcdef

Add the CSP header to the request router config, reload it, then refresh the browser page with the dev tools open. If anything does not load correctly, update the CSP header as needed. Note, that if you using HACS to load custom integrations, the repository pages will be intentionally partially broken. HACS shows the README.md from the GitHub repository, and often times developers include images from other origins, such as img.shields.io as “shield icons” that show things like below:

A screenshot of a few different small badge images showing the latest GitHub release version, how many users are on Discord, HACS support, and number of sponsors.

I intentionally didn’t add this for privacy reasons and they don’t add a lot of value. However, if you want to see them, just add https://img.shields.io to your image-src.

Analysis of my header

Home Assistant is intentionally quite flexible in what JS code it can run. Since you can load arbitrary cards through HACS which bring in their own HTML, CSS, JS, and developers sometimes use inline <script>foo();</script> tags, I had to enable unsafe-inline. This is a common source of drive-by XSS attacks and would still leave me susceptible to things like CVE-2017-16782 and CVE-2023-41896 since you could still inject JavaScript through incorrectly escaped content. Unescaped content isn’t the only source of XSS attacks though.

The next layer of defense is what I set for image-src, style-src, and connect-src which are the mechanisms to then exfiltrate data to a malicious actor. Any origin listed there should be considered relevant trusted. Don’t add * or anything.

Problems with iframes in Firefox

I’m using the weather-radar-card to show precipitation clouds. This required adding https://tilecache.rainviewer.com to my image-src directive. However after setting the CSP header, it would only show the first time I loaded the page, but not if it was in the cache. I had to hard refresh (shift-F5) to get it to load. Otherwise, it’d show an empty white box, like below:

A screenshot showing that the weather-radar-card is not loading. It shows a white box.

It took me awhile to figure this out, because it only seemed to happen in Firefox, but looking at the HTML DOM, it looked like this:

1
<iframe srcdoc="<html><script src="/local/community/weather-radar-card/leaflet.js"></script>

According to the spec, iframe srcdocs are supposed to inherit the parent’s Origin and thus use the same header we set above, but for some reason, it was enforcing the header, but not using the same Origin. Even though we set 'self', it was ignoring that and thought it was different. To fix this, I added ha.example.com to my script-src and image-src.

After that, the card started working:

A screen shot showing the working radar card

Strict Login Header

Just for fun (this is optional), I added a second restricted header just for the login page to reduce my exposure even more.

1
2
3
4
5
Content-Security-Policy:
  default-src 'self' data:;
  script-src 'self' 'unsafe-inline';
  style-src 'self' 'unsafe-inline';
  frame-ancestors 'none'

All Together

Now, putting it all together, my Content-Security-Policy looks like:

1
2
3
4
5
6
7
Content-Security-Policy: 
  default-src 'self' data:;
  script-src 'self' 'unsafe-inline' 'unsafe-eval' https://ha.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://ha.example.com https://barkinglabs-media.s3.amazonaws.com https://basemaps.cartocdn.com https://*.basemaps.cartocdn.com https://brands.home-assistant.io https://tilecache.rainviewer.com https://raw.githubusercontent.com;
  connect-src 'self' https://brands.home-assistant.io https://raw.githubusercontent.com;
  frame-ancestors 'none'

And inserting that into my K8s Ingress:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    # AuthN/Z handled by HA itself
    nginx.ingress.kubernetes.io/enable-global-auth: "false"
    nginx.ingress.kubernetes.io/configuration-snippet: >
      add_header Content-Security-Policy "default-src 'self' data:; script-src
      'self' 'unsafe-inline' 'unsafe-eval' https://ha.example.com; style-src 'self' 'unsafe-inline';
      img-src 'self' data: https://ha.example.com https://barkinglabs-media.s3.amazonaws.com
      https://basemaps.cartocdn.com https://*.basemaps.cartocdn.com
      https://brands.home-assistant.io https://tilecache.rainviewer.com
      https://raw.githubusercontent.com; connect-src 'self'
      https://brands.home-assistant.io https://raw.githubusercontent.com;
      frame-ancestors 'none';";      
  name: homeassistant
  namespace: smarthome
spec:
  ingressClassName: external-nginx
  rules:
  - host: ha.example.com
    http:
      paths:
      - backend:
          service:
            name: homeassistant-headless
            port:
              number: 8123
        path: /
        pathType: Prefix
  tls:
  - hosts:
    - ha.example.com
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/configuration-snippet: >-
      add_header Content-Security-Policy "default-src 'self' data:; script-src
      'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'";      
    nginx.ingress.kubernetes.io/enable-global-auth: 'false'
  name: homeassistant-login
  namespace: smarthome
spec:
  ingressClassName: external-nginx
  rules:
    - host: ha.example.com
      http:
        paths:
          - backend:
              service:
                name: homeassistant-headless
                port:
                  number: 8123
            path: /auth/
            pathType: Prefix
  tls:
    - hosts:
        - ha.example.com
      secretName: home-wildcard

Conclusion

Content-Security-Policy is a useful security feature that mitigates many (but not all) types of XSS attacks in the browser. It can be tricky to set in Home Assistant because HA uses a variety of different features like service workers, iframes, and allows arbitrary JS to be loaded by design through custom cards. However, this makes it more important to add a CSP header to protect as much as possible.

Copyright - All Rights Reserved

Comments

Comments are currently unavailable while I move to this new blog platform. To give feedback, send an email to adam [at] this website url.