Security or Convenience? Choose two!

TLDR

Use this set of four traefik rules to secure an Immich installation while still retaining functionality of public share links and Google Cast support:

    labels:
      # immich-share: initial access to /share/
      - "traefik.http.routers.immich-share.rule=Host(`my.host.tld`) && PathRegexp(`^/share/\\S{67}$`)"
      - "traefik.http.routers.immich-share.priority=10"
      # immich-share-referer: following requests from shared links
      - "traefik.http.routers.immich-share-referer.rule=Host(`my.host.tld`) && PathRegexp(`(^/_app/|^/api/|.*icon.*|\\.css$|dark_skeleton.png)`) && HeaderRegexp(`Referer`, `^https://my\\.host\\.tld/(_app/.*|share/\\S{67}$)`)"
      - "traefik.http.routers.immich-share-referer.priority=9"
      # NEW: immich-chromecast: access to /api/assets/<uuid> for ChromeCast devices
      - "traefik.http.routers.immich-chromecast.rule=Host(`my.host.tld`) && PathRegexp(`^/api/assets/[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}/(original|thumbnail)?.*`) && HeaderRegexp(`User-Agent`, `.*DeviceType/AndroidTV$`) && Header(`Referer`, `https://www.gstatic.com/`)"
      - "traefik.http.routers.immich-chromecast.priority=4"
      # immich-auth: basic auth catch-all
      - "traefik.http.routers.immich-auth.rule=Host(`my.host.tld`)"
      - "traefik.http.middlewares.domain-auth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/"
      - "traefik.http.routers.immich-auth.middlewares=domain-auth"
      - "traefik.http.routers.immich-auth.priority=1"

A service on the open Internet? Not without my basic auth

Ever since a friend of mine told me about Immich (an open-source Google Photos alternative) a few months ago, I am self-hosting it behind traefik as most of my other services. Part of this is setup is a basic auth rule I generally apply to hide my services from Internet scanners such as Shodan or Censys1. Using traefik’s dynamic configuration via docker labels the configuration for a service myapp hosted at my.host.tld with the entire service behind basic auth looks like this:

name: myapp

myapp:
    image: myapp:latest
    [ ... ] # configure the rest of the container
    labels:
      # enable traefik for this container
      - "traefik.enable=true"
      # configure the router to match our domain
      - "traefik.http.routers.myapp.rule=Host(`my.host.tld`)"
      # define and add the basic auth middleware
      - "traefik.http.middlewares.domain-auth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/"
      - "traefik.http.routers.myapp.middlewares=domain-auth"

This was working fine as long as I was the only active user. Immich’s Android app supports adding custom headers to any request so you can inject the static basic auth header. But after a recent vacation trip, I wanted to shared some photos with my family without burdening them with a basic auth prompt in the browser. This was a perfect use-case for Immich’s public sharing feature that I had read about but never used until now.

Public shareable links but safe please

Immich’s public links follow common best practices and include a random 67 character component to make them unguessable by unwanted parties2. Following our example from above with Immich hosted at https://my.host.tld, a public share link could look like this:

https://my.host.tld/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k

With the current configuration, hitting these links will result in the expected basic auth prompt before any data is loaded. To avoid this, I needed to update the configuration to allow access to all share links without basic auth while still preserving the behavior for all other endpoints. Luckily traefik’s rule priorities allow for multiple rules to cover the same service which are then evaluated in order. Given that the first matching rule is used, you generally want to go from more specific rules to more general ones to avoid matching the wrong rule first.

Looking at Immich’s share link, we need to allow access to paths starting with ^/share/ followed by the random component \S{67}3. So let’s add a high-priority rule to match this pattern:

    labels:
      - "traefik.enable=true"
      # add the immich-share rule with priority 10 (== matching first)
      - "traefik.http.routers.immich-share.rule=Host(`my.host.tld`) && PathRegexp(`^/share/\\S{67}$`"
      - "traefik.http.routers.immich-share.rule.priority=10"
      - "traefik.http.routers.immich-auth.rule=Host(`my.host.tld`)"
      - "traefik.http.middlewares.domain-auth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/"
      - "traefik.http.routers.immich-auth.middlewares=domain-auth"
      # set the basic auth rule to the lowest priority
      - "traefik.http.routers.immich-auth.priority=1"

However, this will not be enough as only the share link itself will be accessible while ALL other requests (such as scripts, CSS or the images themselves) will fall back to the previous basic auth rule. Resolving this is a highly iterative process that looks like this:

  1. Load the share link in a browser’s dev tools
  2. Check the network tab which requests result in a HTTP 401 status code
  3. Add (or modify) a traefik rule covering the endpoint and restart the docker container (to apply label changes)
  4. Go to 1. until no more requests result in 401

After about 30 minutes of rule tweaking I arrived at the following two rules required for public sharing:

    labels:
      # immich-share: initial access to /share/
      - "traefik.http.routers.immich-share.rule=Host(`my.host.tld`) && PathRegexp(`^/share/\\S{67}$`)"
      - "traefik.http.routers.immich-share.priority=10"
      # immich-share-referer: following requests from shared links
      - "traefik.http.routers.immich-share-referer.rule=Host(`my.host.tld`) && PathRegexp(`(^/_app/|^/api/|.*icon.*|\\.css$|dark_skeleton.png)`) && HeaderRegexp(`Referer`, `^https://my\\.host\\.tld/(_app/.*|share/\\S{67}$)`)"
      - "traefik.http.routers.immich-share-referer.priority=9"

The added rule immich-share-referer allows access to (i) paths beginning with /_app/ or /api/, (ii) icons, (iii) CSS files and (iv) the files dark_skeleton.png. Additionally, the rule checks the Referer HTTP header4 for a value matching our original share link format. While HTTP headers are easily forgeable, this check adds a little more protection from Internet scanners. The scanner would have to know about this link format (and would need to set this as a referrer) without knowing that Immich is hosted on the domain at all. This is a trade-off I am comfortable with especially since the resources accessible via this rule are not directly sensitive themselves and the Immich API is guarded with additional internal authentication.

Mission accomplished: I can now share public links with my family without worrying about my Immich instance being exposed to these pesky Internet scanners. But there is more…

Bonus: Adding support for Google Cast

Around the same time I set out to share my vacation snapshots, Immich added support for Google Cast in v1.135.05. As I was already modifying traefik rules, why not try to come up with a sensible solution to enable Google casting? Similar to the public sharing, the Chromecast (or other Google Cast device) needs to be able to freely access several endpoints on the public Immich instance.

While the “debugging cycle” is similar to the one described above, the Chromecast does not offer dev tools and we need to fall back to traefik access logs. After enabling and tailing them in a terminal, I needed to repeatedly trigger casting via the Android app and check the logs for any 401 responses. Afer another 30 minutes, I had built a rule that once again checks for the paths and referrer (this time https://www.gstatic.com - a Google domain) but also adds a rudimentary check for the user agent:

    labels:
      # immich-chromecast: access to /api/assets/<uuid> for ChromeCast devices
      - "traefik.http.routers.immich-chromecast.rule=Host(`my.host.tld`) && PathRegexp(`^/api/assets/[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}/(original|thumbnail)?.*`) && HeaderRegexp(`User-Agent`, `.*DeviceType/AndroidTV$`) && Header(`Referer`, `https://www.gstatic.com/`)"
      - "traefik.http.routers.immich-chromecast.priority=4"

Combining all rules required for public sharing and Google Cast support, we arrive at the following set of rules (including the basic auth catch-all with the lowest priority):

    labels:
      # immich-share: initial access to /share/
      - "traefik.http.routers.immich-share.rule=Host(`my.host.tld`) && PathRegexp(`^/share/\\S{67}$`)"
      - "traefik.http.routers.immich-share.priority=10"
      # immich-share-referer: following requests from shared links
      - "traefik.http.routers.immich-share-referer.rule=Host(`my.host.tld`) && PathRegexp(`(^/_app/|^/api/|.*icon.*|\\.css$|dark_skeleton.png)`) && HeaderRegexp(`Referer`, `^https://my\\.host\\.tld/(_app/.*|share/\\S{67}$)`)"
      - "traefik.http.routers.immich-share-referer.priority=9"
      # NEW: immich-chromecast: access to /api/assets/<uuid> for ChromeCast devices
      - "traefik.http.routers.immich-chromecast.rule=Host(`my.host.tld`) && PathRegexp(`^/api/assets/[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}/(original|thumbnail)?.*`) && HeaderRegexp(`User-Agent`, `.*DeviceType/AndroidTV$`) && Header(`Referer`, `https://www.gstatic.com/`)"
      - "traefik.http.routers.immich-chromecast.priority=4"
      # immich-auth: basic auth catch-all
      - "traefik.http.routers.immich-auth.rule=Host(`my.host.tld`)"
      - "traefik.http.middlewares.domain-auth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/"
      - "traefik.http.routers.immich-auth.middlewares=domain-auth"
      - "traefik.http.routers.immich-auth.priority=1"

These four rules strike a good balance between security and convenience for my use cases. While scanners could still fingerprint the Immich instance by hitting a share link (Immich will respond with a 404 even for invalid links) or potentially access parts of the API (by presenting a combination of specific headers), the convenience of being able to share links and use Google Cast outweigh these negatives for me. After all, what’s the point of using great open-source tools when you cannot use half the features?


1

Security by obscurity is not a viable defense model but I still like to minimize the information freely obtainable from my online footprint

2

For additional protection you can add an expiration date and password but I won’t discuss this here as it does not change the required traefik configuration

3

Where ^ is the regex character to match “beginning of line” which in our case means beginning of the requested path and \S matches all non-whitespace characters

4

I still chuckle about the typo everytime I come across this header

5

I highly appreciate the very active development and frequent release cycles of Immich. Every other release usually contains a neat feature, performance optimization or other highlight I did not even know I wanted