IoT Web-Interface sicher von außen erreichbar machen

Vielleicht kennt ihr schon das Web-Interface, das ich für Tsia und mein Home-Automation-System gebaut habe. Es ist awesome, weil es Custom Elements, Shadow DOM, Server-sent Events und andere neue Web-Technologien nutzt. Aber wie macht man sowas sicher von außen erreichbar?

Der Server, auf dem diese Web-Anwendung läuft, ist ein kleines Intel Desktop Board. Es gibt keinen für die Web-App spezifischen Server-Part, da sie keinen Build-Prozess hat. Die Assets werden einfach von einem nginx ausgeliefert, der zudem API-Calls zur IoT-Anwendung proxyt. Das Ausliefern von zu Hause bringt Unabhängigkeit vom Internet, was ich bei IoT-Zeug immer begrüße. Trotzdem wäre es cool, wenn man nicht jedes Mal einen VPN-Client auf dem Telefon starten müsste, nur um schnell zu gucken, ob man auch alle Fenster zugemacht hat.
Screenshot des IoT-Web-Interfaces

DNS

Den DNS-Eintrag von „hermes“ (so heißt der Server, der das Web-Interface ausliefert) auch von außen auflösbar zu machen würde nichts bringen, weil wir zum einen natürlich keine öffentlichen IPv4-Adressen haben und zum anderen NPTv6 nutzen.

;; ANSWER SECTION:
hermes.net.wurstsalat.cloud. 60    IN  AAAA    2abc:dead:beef::101:0:0  
hermes.net.wurstsalat.cloud. 60    IN  A   10.97.0.101

;; AUTHORITY SECTION:
net.wurstsalat.cloud.    60  IN  NS  farnsworth.net.wurstsalat.cloud.

;; ADDITIONAL SECTION:
farnsworth.net.wurstsalat.cloud. 60 IN    A   10.97.0.1  
farnsworth.net.wurstsalat.cloud. 60 IN    AAAA    2abc:dead:beef::1  

Daher haben wir uns entschieden, einen Proxy für all die Web-Interfaces einzurichten, die – hinter einer Authentifizierung – auch von außen erreichbar sein sollen.
Screenshot der Startseite von „https://i.wurstsalat.cloud“

Alles unter i.wurstsalat.cloud löst von innen mit den internen Adressen dieses Proxys auf.

;; ANSWER SECTION:
i.wurstsalat.cloud.    60  IN  SOA farnsworth.net.wurstsalat.cloud. dns-admin.wurstsalat.cloud. 1556991248 60 60 60 60  
i.wurstsalat.cloud.    60  IN  NS  farnsworth.net.wurstsalat.cloud.  
i.wurstsalat.cloud.    60  IN  A   10.97.0.49  
i.wurstsalat.cloud.    60  IN  AAAA    2abc:dead:beef::40:0:9

;; ADDITIONAL SECTION:
farnsworth.net.wurstsalat.cloud. 60 IN    A   10.97.0.1  
farnsworth.net.wurstsalat.cloud. 60 IN    AAAA    2abc:dead:beef::1  

Von außen wird i.wurstsalat.cloud auf CNAME farnsworth.wurstsalat.cloud aufgelöst, was wiederum immer auf die aktuellen externen IP-Adressen unseres Routers zeigt.

;; ANSWER SECTION:
iot.i.wurstsalat.cloud.    59  IN  CNAME   farnsworth.wurstsalat.cloud.  
;; ANSWER SECTION:
farnsworth.wurstsalat.cloud. 4    IN  A   149.233.134.95  
farnsworth.wurstsalat.cloud. 4    IN  AAAA    2a04:4540:6811:f501::1  

Der Router kann dann die von außen kommenden Requests auf den Proxy portmappen. Durch die geringe DNS-TTL nutzen Clients immer den richtigen Weg.

Damit haben wir nun von außen erreichbare Web-Services.
Aber wie sichert man das ganze jetzt ab?

HTTP(S) und Authentication

Zunächst kann man annehmen, dass Tsia und ich, ohne größere Zwischenfälle, immer nach spätestens 14 Tagen wieder im heimischen Netzwerk sein werden. Es wäre daher erstrebenswert, wenn dadurch automatisch ein Login erfolgt, der für 14 Tage gültig bleibt und von außen nutzbar ist. So kann man sich auch das Bauen einer Login-Maske ersparen.

Das hat Tsia folgendermaßen implementiert:
Wenn man das IoT-Webinterface aufrufen will, nutzt man statt bisher https://hermes.net.wurstsalat.cloud/ von nun an https://iot.i.wurstsalat.cloud/. Diese Domain wird von innen wie außen so aufgelöst, dass man am Ende beim Proxy-Server raus kommt (siehe oben). Auf diesem leitet das nginx-Modul auth_request die Header des HTTP-Requests an https://auth.i.wurstsalat.cloud/?action=verify weiter.

Unter dieser Adresse läuft ein winziges PHP-Script, das einfach nur schaut, ob der Request ein Cookie mit einem gültigen Authentication-Token enthält. Ist dies der Fall, antwortet es mit 200, was auth_request dazu veranlasst, den Request an den Upstream-Server https://hermes.net.wurstsalat.cloud/ durchzuproxyen (geiles Wort).

Falls man sich noch nicht authentifiziert hat, antwortet das PHP-Script mit 401, was nginx dazu bringt, auf den Request mit einem 302-Redirect auf die URL https://auth.i.wurstsalat.cloud/?url=https://iot.i.wurstsalat.cloud zu antworten.

Wenn man sich nicht bei uns im Netzwerk befindet, ist an dieser Stelle Schluss, denn https://auth.i.wurstsalat.cloud/ ist nicht von außen erreichbar.
Befindet man sich jedoch im Netzwerk, generiert das PHP-Script ein Auth-Token, speichert dieses, sendet es in einem Cookie und 302-redirectet dann zurück auf die ursprüngliche URL.

Cookies und Web-Technologien

Wenn die Web-App auf einmal Cookies und den Authentication-Flow korrekt handhaben soll, muss auf ein paar Dinge geachtet werden.

Zum einen habe ich feststellen müssen, dass fetch nur in manchen Browsern standardmäßig Redirects folgt.
Daher in den Options von fetch immer { redirect: 'follow' } eintragen.

Außerdem muss man auf ein paar Dinge achten, damit Browser trotz Sicherheits-Features das Cookie immer mitsenden:

  • fetch-Calls sollten in ihren Options { credentials: 'include' } gesetzt haben
  • Script- und etwaige Preload-Tags im HTML-Markup brauchen das Attribut crossorigin="use-credentials"
  • Da Service-Worker dafür sorgen können, dass API-Calls als einzige tatsächlich ins Netzwerk gehen, weil alles andere aus dem Cache kommt, sollten alle Services im Authentication-Flow korrekte CORS-Header gesetzt haben, auch solche, die nur mit einem Redirect antworten

Zum Schluss sei gesagt, dass man die Service-Worker.js und, falls vorhanden, die manifest.json aus der Authentication raus nehmen muss, weil Browser für diese nie Cookies mitsenden.